@7365admin1/layer-common 1.11.40 → 1.11.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 1.11.42
4
+
5
+ ### Patch Changes
6
+
7
+ - e11278e: updates and fixes
8
+
9
+ ## 1.11.41
10
+
11
+ ### Patch Changes
12
+
13
+ - bc096ed: Fix navigation not get children item
14
+ - 5031c18: Update Vehicle Management
15
+
3
16
  ## 1.11.40
4
17
 
5
18
  ### Patch Changes
@@ -5,6 +5,96 @@
5
5
  :site-id="siteId"
6
6
  />
7
7
 
8
+ <!-- Assign to Person Dialog -->
9
+ <v-dialog :model-value="showAssignForm" width="420" persistent>
10
+ <v-card width="100%">
11
+ <v-toolbar density="compact" color="surface">
12
+ <v-row no-gutters class="fill-height px-4" align="center">
13
+ <span class="font-weight-bold">Assign Card to Person</span>
14
+ </v-row>
15
+ </v-toolbar>
16
+
17
+ <v-card-text class="pa-4">
18
+ <div class="text-caption text-grey mb-4">
19
+ Card: <strong class="text-body-2 text-black">{{ selectedCardInUnit?.cardNo }}</strong>
20
+ </div>
21
+
22
+ <!-- Person Type Filter -->
23
+ <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">Person Type</div>
24
+ <v-btn-toggle
25
+ v-model="assignPersonType"
26
+ color="primary"
27
+ density="compact"
28
+ variant="outlined"
29
+ rounded="pill"
30
+ class="mb-4"
31
+ >
32
+ <v-btn value="" size="small" class="text-none px-3">All</v-btn>
33
+ <v-btn value="owner" size="small" class="text-none px-3">Unit Owner</v-btn>
34
+ <v-btn value="resident" size="small" class="text-none px-3">Resident</v-btn>
35
+ <v-btn value="tenant" size="small" class="text-none px-3">Tenant</v-btn>
36
+ </v-btn-toggle>
37
+
38
+ <!-- Person Selector -->
39
+ <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">
40
+ Person <span class="text-error">*</span>
41
+ </div>
42
+ <v-autocomplete
43
+ v-model="assignPerson"
44
+ :items="filteredPeopleItems"
45
+ density="compact"
46
+ item-title="name"
47
+ item-value="_id"
48
+ hide-details="auto"
49
+ placeholder="Select person..."
50
+ persistent-placeholder
51
+ :loading="peopleLoading"
52
+ return-object
53
+ no-data-text="No residents or tenants found for this unit"
54
+ >
55
+ <template #item="{ props: itemProps, item }">
56
+ <v-list-item v-bind="itemProps" :subtitle="personTypeLabel(item.raw)" />
57
+ </template>
58
+ <template #selection="{ item }">
59
+ <span>{{ item.raw.name }}</span>
60
+ <v-chip size="x-small" class="ml-2" color="grey" variant="tonal">
61
+ {{ personTypeLabel(item.raw) }}
62
+ </v-chip>
63
+ </template>
64
+ </v-autocomplete>
65
+ </v-card-text>
66
+
67
+ <v-toolbar density="compact">
68
+ <v-row no-gutters>
69
+ <v-col cols="6" class="pa-0">
70
+ <v-btn
71
+ block
72
+ variant="text"
73
+ class="text-none"
74
+ height="48"
75
+ @click="cancelAssign"
76
+ >
77
+ Cancel
78
+ </v-btn>
79
+ </v-col>
80
+ <v-col cols="6" class="pa-0">
81
+ <v-btn
82
+ block
83
+ variant="flat"
84
+ color="black"
85
+ class="text-none"
86
+ height="48"
87
+ :disabled="!assignPerson"
88
+ @click="confirmAssign"
89
+ >
90
+ Assign
91
+ </v-btn>
92
+ </v-col>
93
+ </v-row>
94
+ </v-toolbar>
95
+ </v-card>
96
+ </v-dialog>
97
+
8
98
  <v-dialog :model-value="modelValue" width="450" persistent>
9
99
  <v-card width="100%">
10
100
  <v-card-text style="max-height: 100vh; overflow-y: auto" class="pb-0">
@@ -211,6 +301,14 @@
211
301
  </v-btn>
212
302
  </template>
213
303
  <v-list class="pa-0">
304
+ <v-list-item
305
+ v-if="canAssignToPerson && isSelectedCardAvailable"
306
+ @click="openAssignForm"
307
+ >
308
+ <v-list-item-title class="text-subtitle-2">
309
+ Assign to Person
310
+ </v-list-item-title>
311
+ </v-list-item>
214
312
  <v-list-item
215
313
  :disabled="!isSelectedCardAssignedPhysical"
216
314
  @click="emit('replace')"
@@ -270,6 +368,10 @@ const props = defineProps({
270
368
  type: Boolean,
271
369
  default: true,
272
370
  },
371
+ canAssignToPerson: {
372
+ type: Boolean,
373
+ default: true,
374
+ },
273
375
  isSelectedCardAssignedPhysical: {
274
376
  type: Boolean,
275
377
  default: false,
@@ -289,9 +391,26 @@ const emit = defineEmits<{
289
391
  "update:selectedCardInUnit": [value: Record<string, any> | null];
290
392
  replace: [];
291
393
  delete: [];
394
+ "assign-to-person": [{ card: Record<string, any> | null; person: Record<string, any> | null }];
292
395
  }>();
293
396
 
397
+ const { getPeopleByUnit: _getPeopleByUnit } = usePeople();
398
+
294
399
  const historyDialog = ref(false);
400
+ const showAssignForm = ref(false);
401
+ const assignPersonType = ref<string>("");
402
+ const assignPerson = ref<Record<string, any> | null>(null);
403
+ const peopleItems = ref<Record<string, any>[]>([]);
404
+ const peopleLoading = ref(false);
405
+
406
+ const isSelectedCardAvailable = computed(() => {
407
+ if (!props.selectedCardInUnit?._id) return false;
408
+ const id = props.selectedCardInUnit._id;
409
+ return [
410
+ ...(props.unit?.available?.physical ?? []),
411
+ ...(props.unit?.available?.non_physical ?? []),
412
+ ].some((c) => c._id === id);
413
+ });
295
414
 
296
415
  const isSelectedCardDeletable = computed(() => {
297
416
  if (!props.selectedCardInUnit?._id) return false;
@@ -304,6 +423,51 @@ const isSelectedCardDeletable = computed(() => {
304
423
  ].some((c) => c._id === id);
305
424
  });
306
425
 
426
+ const filteredPeopleItems = computed(() => {
427
+ if (!assignPersonType.value) return peopleItems.value;
428
+ if (assignPersonType.value === "owner") return peopleItems.value.filter((p) => p.isOwner);
429
+ return peopleItems.value.filter((p) => p.type === assignPersonType.value && !p.isOwner);
430
+ });
431
+
432
+ const personTypeLabel = (person: Record<string, any>) => {
433
+ if (person.isOwner) return "Unit Owner";
434
+ if (person.type === "resident") return "Resident";
435
+ if (person.type === "tenant") return "Tenant";
436
+ return person.type ?? "";
437
+ };
438
+
439
+ async function openAssignForm() {
440
+ showAssignForm.value = true;
441
+ assignPerson.value = null;
442
+ assignPersonType.value = "";
443
+ peopleItems.value = [];
444
+
445
+ if (!props.unit?._id) return;
446
+ peopleLoading.value = true;
447
+ try {
448
+ const res = await _getPeopleByUnit(props.unit._id);
449
+ peopleItems.value = res?.data ?? [];
450
+ } catch {
451
+ peopleItems.value = [];
452
+ } finally {
453
+ peopleLoading.value = false;
454
+ }
455
+ }
456
+
457
+ function cancelAssign() {
458
+ showAssignForm.value = false;
459
+ assignPerson.value = null;
460
+ assignPersonType.value = "";
461
+ }
462
+
463
+ function confirmAssign() {
464
+ emit("assign-to-person", {
465
+ card: props.selectedCardInUnit,
466
+ person: assignPerson.value,
467
+ });
468
+ cancelAssign();
469
+ }
470
+
307
471
  function toggleCard(card: Record<string, any>) {
308
472
  emit(
309
473
  "update:selectedCardInUnit",
@@ -6,7 +6,8 @@
6
6
 
7
7
  <!-- Loading Skeleton -->
8
8
  <template v-if="loading && files?.length > 0">
9
- <v-card v-for="(file, index) in files" :key="index" height="50px" flat border="sm black" class="mb-2">
9
+ <v-card v-for="(file, index) in files" :key="index" height="50px" flat border="sm black"
10
+ class="mb-2">
10
11
  <v-skeleton-loader type="list-item" class="mb-2" />
11
12
  </v-card>
12
13
  </template>
@@ -56,7 +57,7 @@
56
57
  </template>
57
58
  <v-list-item-title class="text-body-2">{{ item.file.name }}</v-list-item-title>
58
59
  <v-list-item-subtitle class="text-caption">{{ item.mimeType
59
- }}</v-list-item-subtitle>
60
+ }}</v-list-item-subtitle>
60
61
  <template #append>
61
62
  <v-icon size="16" color="grey-lighten-1">mdi-eye-outline</v-icon>
62
63
  </template>
@@ -81,7 +82,7 @@
81
82
  </template>
82
83
  <v-list-item-title class="text-body-2">{{ item.file.name }}</v-list-item-title>
83
84
  <v-list-item-subtitle class="text-caption">{{ item.mimeType
84
- }}</v-list-item-subtitle>
85
+ }}</v-list-item-subtitle>
85
86
  <template #append>
86
87
  <v-icon size="16" color="grey-lighten-1">mdi-open-in-new</v-icon>
87
88
  </template>
@@ -200,26 +201,54 @@ watchEffect(async () => {
200
201
  for (const item of (props.files || [])) {
201
202
  // Handle building files format: { id, name }
202
203
  if (item.id && item.name && !item.file) {
204
+ let file: File = new File([], item.name);
203
205
  let mimeType = 'application/octet-stream';
206
+
204
207
  try {
205
- const fileMeta = await getFileById(item.id) as any;
206
- const apiMimeType = fileMeta?.data?.mimeType || fileMeta?.mimeType;
207
- // Use API mime type only if it's not generic
208
- if (apiMimeType && apiMimeType !== 'application/octet-stream') {
209
- mimeType = apiMimeType;
210
- } else {
211
- // Fallback to filename-based detection
208
+ // Try to get the actual file with proper MIME type detection from blob
209
+ const fileUrl = getFileUrl(item.id);
210
+ file = await urlToFile(fileUrl, item.name);
211
+ mimeType = file.type || 'application/octet-stream';
212
+
213
+ // If blob detection returned generic type, try API metadata or filename
214
+ if (mimeType === 'application/octet-stream') {
215
+ try {
216
+ const fileMeta = await getFileById(item.id) as any;
217
+ const apiMimeType = fileMeta?.data?.mimeType || fileMeta?.mimeType;
218
+ if (apiMimeType && apiMimeType !== 'application/octet-stream') {
219
+ mimeType = apiMimeType;
220
+ } else {
221
+ const ext = item.name?.split('.').pop()?.toLowerCase();
222
+ mimeType = inferMimeType(ext);
223
+ }
224
+ } catch {
225
+ // Fallback to filename-based detection
226
+ const ext = item.name?.split('.').pop()?.toLowerCase();
227
+ mimeType = inferMimeType(ext);
228
+ }
229
+ }
230
+ } catch {
231
+ // Fallback: try API metadata
232
+ try {
233
+ const fileMeta = await getFileById(item.id) as any;
234
+ const apiMimeType = fileMeta?.data?.mimeType || fileMeta?.mimeType;
235
+ if (apiMimeType && apiMimeType !== 'application/octet-stream') {
236
+ mimeType = apiMimeType;
237
+ } else {
238
+ // Fallback to filename-based detection
239
+ const ext = item.name?.split('.').pop()?.toLowerCase();
240
+ mimeType = inferMimeType(ext);
241
+ }
242
+ } catch {
243
+ // Final fallback: infer from filename
212
244
  const ext = item.name?.split('.').pop()?.toLowerCase();
213
245
  mimeType = inferMimeType(ext);
214
246
  }
215
- } catch {
216
- // Fallback: infer MIME type from file name
217
- const ext = item.name?.split('.').pop()?.toLowerCase();
218
- mimeType = inferMimeType(ext);
219
247
  }
248
+
220
249
  processedFiles.push({
221
250
  _id: item.id,
222
- file: new File([], item.name, { type: mimeType }),
251
+ file,
223
252
  preview: item.preview ?? false,
224
253
  mimeType
225
254
  });
@@ -52,12 +52,12 @@
52
52
 
53
53
  <v-col cols="12">
54
54
  <InputLabel class="text-capitalize" title="Start Date" required />
55
- <InputDateTimePicker v-model:utc="bulletinForm.startDate" :rules="[validStartDateRule]" />
55
+ <InputDateTimePicker v-model:utc="bulletinForm.startDate" :rules="[validStartDateRule]"/>
56
56
  </v-col>
57
57
 
58
58
  <v-col cols="12" v-if="!bulletinForm.noExpiration">
59
59
  <InputLabel class="text-capitalize" title="End Date" required />
60
- <InputDateTimePicker v-model:utc="bulletinForm.endDate" :rules="[validExpiryDateRule]" />
60
+ <InputDateTimePicker v-model:utc="bulletinForm.endDate" :rules="[validExpiryDateRule]" :min="today" />
61
61
  </v-col>
62
62
 
63
63
  <v-col cols="12">
@@ -103,6 +103,15 @@
103
103
  </template>
104
104
 
105
105
  <script setup lang="ts">
106
+ const today = computed(() => {
107
+ const d = new Date()
108
+ const yyyy = d.getFullYear()
109
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
110
+ const dd = String(d.getDate()).padStart(2, '0')
111
+ console.log('${yyyy}-${mm}-${dd}',`${yyyy}-${mm}-${dd}`)
112
+ return `${yyyy}-${mm}-${dd}`
113
+ })
114
+
106
115
  const prop = defineProps({
107
116
  siteId: {
108
117
  type: String,
@@ -214,6 +214,7 @@ function eventStatusFormat(status: any) {
214
214
 
215
215
  const tabOptions = [
216
216
  { name: "Active", status: "active" },
217
+ { name: "Upcoming", status: "upcoming" },
217
218
  { name: "Expired", status: "expired" },
218
219
  ];
219
220
 
@@ -332,7 +333,7 @@ watch([searchInput], ([search]) => {
332
333
 
333
334
  onMounted(() => {
334
335
  const statusQuery = (useRoute()?.query?.status)
335
- status.value = (statusQuery === "active" || statusQuery === "expired") ? statusQuery : "active"
336
+ status.value = (statusQuery === "active" || statusQuery === "upcoming" || statusQuery === "expired") ? statusQuery : "active"
336
337
  })
337
338
 
338
339
  </script>
@@ -34,8 +34,7 @@
34
34
 
35
35
  <!-- ACTIONS -->
36
36
  <template #actions>
37
- <v-btn v-if="tab !== 'suspended'" class="text-none" rounded="pill" variant="tonal" size="large"
38
- @click="openInviteDialog">
37
+ <v-btn class="text-none" rounded="pill" variant="tonal" size="large" @click="openInviteDialog">
39
38
  Invite Client
40
39
  </v-btn>
41
40
  </template>
@@ -71,7 +70,7 @@
71
70
  </template>
72
71
 
73
72
  <script setup lang="ts">
74
- import { computed, onMounted, ref } from 'vue'
73
+ import { computed, nextTick, onMounted, ref } from 'vue'
75
74
  import useOrg from '../composables/useOrg'
76
75
  import useVerification from '../composables/useVerification'
77
76
  import useRole from '../composables/useRole'
@@ -82,7 +81,7 @@ import useSubscription from '../composables/useSubscription'
82
81
  const tab = ref<'active' | 'suspended' | 'pending'>('active')
83
82
  const dialog = ref(false)
84
83
 
85
- const { getAll } = useOrg()
84
+ const { getAllV2 } = useOrg()
86
85
  const { getVerifications, cancelUserInvitation } = useVerification()
87
86
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
88
87
  const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
@@ -112,6 +111,7 @@ const APP_LIST = [
112
111
  /* ================= HEADERS ================= */
113
112
  const orgHeaders = [
114
113
  { title: 'Organization Name', key: 'name' },
114
+ { title: 'Email', key: 'email' },
115
115
  { title: 'Sites', key: 'sites' },
116
116
  { title: 'Plan Type', key: 'planType' },
117
117
  { title: 'Billing Cycle', key: 'billingCycle' },
@@ -143,9 +143,22 @@ const pageRange = computed(() => {
143
143
  })
144
144
 
145
145
  /* ================= HELPERS ================= */
146
- function formatDate(date?: string) {
147
- if (!date) return '-'
148
- return new Date(date).toLocaleDateString('en-US')
146
+ function formatDate(date?: string | Date) {
147
+ if (date == null || date === '') return '-'
148
+ const d = typeof date === 'object' && date instanceof Date ? date : new Date(date as string)
149
+ if (Number.isNaN(d.getTime())) return '-'
150
+ return d.toLocaleDateString('en-US')
151
+ }
152
+
153
+ /** Subscription document không luôn có `paidType`; fallback theo type/description. */
154
+ function planTypeLabel(sub: Record<string, any>) {
155
+ if (!sub) return '-'
156
+ if (sub.paidType) return String(sub.paidType)
157
+ if (sub.type === 'organization') return 'Organization'
158
+ if (sub.type === 'affiliate') return 'Affiliate'
159
+ const desc = String(sub.description || '').trim()
160
+ if (desc) return desc.length > 48 ? `${desc.slice(0, 45)}…` : desc
161
+ return '-'
149
162
  }
150
163
 
151
164
  async function resolveRoleName(id: string) {
@@ -159,10 +172,11 @@ async function resolveRoleName(id: string) {
159
172
  const limit = ref(10)
160
173
  /* ================= FETCH ================= */
161
174
  async function fetchActive() {
162
- const res = await getAll({
175
+ const res = await getAllV2({
163
176
  page: page.value,
164
177
  search: search.value,
165
178
  limit: limit.value,
179
+ status: 'active',
166
180
  })
167
181
 
168
182
  const orgs = res?.data?.items || res?.items || []
@@ -189,7 +203,7 @@ async function fetchActive() {
189
203
  const subscription = subRes?.data || subRes
190
204
 
191
205
  if (subscription) {
192
- planType = subscription?.paidType || '-'
206
+ planType = planTypeLabel(subscription)
193
207
  billingCycle = subscription?.billingCycle || '-'
194
208
  subscriptionStart = formatDate(subscription?.createdAt)
195
209
  subscriptionEnd = formatDate(subscription?.nextBillingDate)
@@ -207,6 +221,7 @@ async function fetchActive() {
207
221
  subscriptionStart,
208
222
  subscriptionEnd,
209
223
  status: org?.status || 'active',
224
+ actions: '—',
210
225
  }
211
226
  })
212
227
  )
@@ -225,10 +240,11 @@ async function fetchActive() {
225
240
  }
226
241
 
227
242
  async function fetchSuspended() {
228
- const res = await getAll({
243
+ const res = await getAllV2({
229
244
  page: page.value,
230
245
  search: search.value,
231
- // status: 'suspended',
246
+ limit: limit.value,
247
+ status: 'suspended',
232
248
  })
233
249
  const orgs = res?.data?.items || res?.items || []
234
250
 
@@ -252,7 +268,7 @@ async function fetchSuspended() {
252
268
  const subRes = await getSubscriptionByOrgId(org._id)
253
269
  const subscription = subRes?.data || subRes
254
270
  if (subscription) {
255
- planType = subscription?.paidType || '-'
271
+ planType = planTypeLabel(subscription)
256
272
  billingCycle = subscription?.billingCycle || '-'
257
273
  subscriptionStart = formatDate(subscription?.createdAt)
258
274
  subscriptionEnd = formatDate(subscription?.nextBillingDate)
@@ -262,7 +278,7 @@ async function fetchSuspended() {
262
278
  }
263
279
 
264
280
  return {
265
- _id: org._id,
281
+ _id: `${org._id}-${page.value}`,
266
282
  ...org,
267
283
  sites: `${sitesCount} Sites`,
268
284
  planType,
@@ -270,17 +286,17 @@ async function fetchSuspended() {
270
286
  subscriptionStart,
271
287
  subscriptionEnd,
272
288
  status: org?.status || 'suspended',
289
+ actions: '—',
273
290
  }
274
291
  })
275
292
  )
276
293
 
294
+ items.value = []
295
+ await nextTick()
277
296
  items.value = [...mapped]
278
-
279
- pages.value = res?.pages || 1
280
-
281
- const match = res?.pageRange?.match(/of\s+(\d+)/i)
282
-
283
- totalItems.value = match ? Number(match[1]) : mapped.length
297
+ pages.value = res?.pages || 1
298
+ const match = res?.pageRange?.match(/of\s+(\d+)/i)
299
+ totalItems.value = match ? Number(match[1]) : mapped.length
284
300
  }
285
301
 
286
302
  async function fetchPending() {
@@ -322,9 +338,7 @@ async function fetchData() {
322
338
  await fetchActive()
323
339
  }
324
340
  else if (tab.value === 'suspended') {
325
- items.value = []
326
- pages.value = 1
327
- totalItems.value = 0
341
+ await fetchSuspended()
328
342
  }
329
343
  else {
330
344
  await fetchPending()
@@ -7,7 +7,7 @@
7
7
  </template>
8
8
  </v-text-field>
9
9
  <div class="w-100 d-flex align-end ga-3 hidden-input">
10
- <input ref="dateInput" :type="inputType" v-model="dateTime" />
10
+ <input ref="dateInput" :type="inputType" v-model="dateTime" :min="minValue" />
11
11
  </div>
12
12
  </div>
13
13
  </template>
@@ -27,6 +27,10 @@ const prop = defineProps({
27
27
  dateOnly: {
28
28
  type: Boolean,
29
29
  default: false
30
+ },
31
+ min: {
32
+ type: String,
33
+ default: undefined
30
34
  }
31
35
  })
32
36
 
@@ -36,6 +40,15 @@ const dateTimeUTC = defineModel<string | null>('utc', { default: null }) // UTC
36
40
 
37
41
  const dateTimeFormattedReadOnly = ref<string | null>(null)
38
42
  const inputType = computed(() => (prop.dateOnly ? 'date' : 'datetime-local'))
43
+
44
+
45
+ const minValue = computed(() => {
46
+ if (!prop.min) return undefined
47
+ if (/^\d{4}-\d{2}-\d{2}$/.test(prop.min)) {
48
+ return prop.dateOnly ? prop.min : `${prop.min}T00:00`
49
+ }
50
+ return prop.min
51
+ })
39
52
  const inputPlaceholder = computed(() => (
40
53
  prop.placeholder || (prop.dateOnly ? 'MM/DD/YYYY' : 'DD/MM/YYYY, HH:MM AM/PM')
41
54
  ))