@7365admin1/layer-common 1.11.45 → 1.11.47

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,19 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 1.11.47
4
+
5
+ ### Patch Changes
6
+
7
+ - 3ca3d41: fix search, filter and add get orgs with subscription
8
+ - ecacc9d: update site category, get permissions by invitation
9
+
10
+ ## 1.11.46
11
+
12
+ ### Patch Changes
13
+
14
+ - 1e4f7b6: update latest version add prop for invitation form
15
+ - 2e14129: update latest layer common
16
+
3
17
  ## 1.11.45
4
18
 
5
19
  ### Patch Changes
@@ -1,4 +1,6 @@
1
1
  <template>
2
+ <Snackbar v-model="snackbar" :text="snackbarMessage" :color="snackbarColor" />
3
+
2
4
  <AccessCardDetailsDialog
3
5
  v-model="historyDialog"
4
6
  :card="selectedCardInUnit"
@@ -20,7 +22,7 @@
20
22
  </div>
21
23
 
22
24
  <!-- Person Type Filter -->
23
- <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">Person Type</div>
25
+ <!-- <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">Person Type</div>
24
26
  <v-btn-toggle
25
27
  v-model="assignPersonType"
26
28
  color="primary"
@@ -33,7 +35,7 @@
33
35
  <v-btn value="owner" size="small" class="text-none px-3">Unit Owner</v-btn>
34
36
  <v-btn value="resident" size="small" class="text-none px-3">Resident</v-btn>
35
37
  <v-btn value="tenant" size="small" class="text-none px-3">Tenant</v-btn>
36
- </v-btn-toggle>
38
+ </v-btn-toggle> -->
37
39
 
38
40
  <!-- Person Selector -->
39
41
  <div class="text-caption font-weight-bold mb-1 text-uppercase text-grey-darken-1">
@@ -62,6 +64,7 @@
62
64
  </v-chip>
63
65
  </template>
64
66
  </v-autocomplete>
67
+ <p v-if="assignError" class="text-error text-caption mt-2">{{ assignError }}</p>
65
68
  </v-card-text>
66
69
 
67
70
  <v-toolbar density="compact">
@@ -84,7 +87,8 @@
84
87
  color="black"
85
88
  class="text-none"
86
89
  height="48"
87
- :disabled="!assignPerson"
90
+ :disabled="!assignPerson || assignLoading"
91
+ :loading="assignLoading"
88
92
  @click="confirmAssign"
89
93
  >
90
94
  Assign
@@ -384,6 +388,10 @@ const props = defineProps({
384
388
  type: String,
385
389
  default: "",
386
390
  },
391
+ orgId: {
392
+ type: String,
393
+ default: "",
394
+ },
387
395
  });
388
396
 
389
397
  const emit = defineEmits<{
@@ -391,10 +399,11 @@ const emit = defineEmits<{
391
399
  "update:selectedCardInUnit": [value: Record<string, any> | null];
392
400
  replace: [];
393
401
  delete: [];
402
+ refresh: [];
394
403
  "assign-to-person": [{ card: Record<string, any> | null; person: Record<string, any> | null }];
395
404
  }>();
396
405
 
397
- const { getPeopleByUnit: _getPeopleByUnit } = usePeople();
406
+ const { getResidents, assignUser } = useAccessManagement();
398
407
 
399
408
  const historyDialog = ref(false);
400
409
  const showAssignForm = ref(false);
@@ -402,6 +411,18 @@ const assignPersonType = ref<string>("");
402
411
  const assignPerson = ref<Record<string, any> | null>(null);
403
412
  const peopleItems = ref<Record<string, any>[]>([]);
404
413
  const peopleLoading = ref(false);
414
+ const assignLoading = ref(false);
415
+ const assignError = ref("");
416
+ const encryptedAcmUrl = ref("");
417
+ const snackbar = ref(false);
418
+ const snackbarMessage = ref("");
419
+ const snackbarColor = ref<"success" | "error">("success");
420
+
421
+ function showToast(message: string, color: "success" | "error") {
422
+ snackbarMessage.value = message;
423
+ snackbarColor.value = color;
424
+ snackbar.value = true;
425
+ }
405
426
 
406
427
  const isSelectedCardAvailable = computed(() => {
407
428
  if (!props.selectedCardInUnit?._id) return false;
@@ -440,13 +461,19 @@ async function openAssignForm() {
440
461
  showAssignForm.value = true;
441
462
  assignPerson.value = null;
442
463
  assignPersonType.value = "";
464
+ assignError.value = "";
443
465
  peopleItems.value = [];
466
+ encryptedAcmUrl.value = "";
444
467
 
445
- if (!props.unit?._id) return;
468
+ if (!props.unit?._id || !props.orgId || !props.siteId) return;
446
469
  peopleLoading.value = true;
447
470
  try {
448
- const res = await _getPeopleByUnit(props.unit._id);
449
- peopleItems.value = res?.data ?? [];
471
+ const [residentsRes, acmRes] = await Promise.all([
472
+ getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id }),
473
+ $fetch<{ encrypted: string }>("/api/encrypt-acm-url"),
474
+ ]);
475
+ peopleItems.value = residentsRes?.data ?? [];
476
+ encryptedAcmUrl.value = acmRes?.encrypted ?? "";
450
477
  } catch {
451
478
  peopleItems.value = [];
452
479
  } finally {
@@ -458,14 +485,44 @@ function cancelAssign() {
458
485
  showAssignForm.value = false;
459
486
  assignPerson.value = null;
460
487
  assignPersonType.value = "";
488
+ assignError.value = "";
461
489
  }
462
490
 
463
- function confirmAssign() {
464
- emit("assign-to-person", {
465
- card: props.selectedCardInUnit,
466
- person: assignPerson.value,
467
- });
468
- cancelAssign();
491
+ const selectedCardType = computed(() => {
492
+ const id = props.selectedCardInUnit?._id;
493
+ if (!id) return "QRCODE";
494
+ const isPhysical = [
495
+ ...(props.unit?.available?.physical ?? []),
496
+ ...(props.unit?.assigned?.physical ?? []),
497
+ ].some((c: any) => c._id === id);
498
+ return isPhysical ? "NFC" : "QRCODE";
499
+ });
500
+
501
+ async function confirmAssign() {
502
+ if (!assignPerson.value || !props.unit?._id) return;
503
+ assignLoading.value = true;
504
+ assignError.value = "";
505
+ try {
506
+ await assignUser({
507
+ assignees: [assignPerson.value._id],
508
+ unit: props.unit._id,
509
+ type: selectedCardType.value,
510
+ acm_url: encryptedAcmUrl.value,
511
+ });
512
+ emit("assign-to-person", {
513
+ card: props.selectedCardInUnit,
514
+ person: assignPerson.value,
515
+ });
516
+ showToast("Card assigned successfully.", "success");
517
+ cancelAssign();
518
+ emit("refresh");
519
+ } catch (e: any) {
520
+ const msg = e?.data?.message ?? "Failed to assign. Please try again.";
521
+ assignError.value = msg;
522
+ showToast(msg, "error");
523
+ } finally {
524
+ assignLoading.value = false;
525
+ }
469
526
  }
470
527
 
471
528
  function toggleCard(card: Record<string, any>) {
@@ -113,8 +113,10 @@
113
113
  :is-selected-card-assigned-physical="isSelectedCardAssignedPhysical"
114
114
  :is-selected-card-physical="isSelectedCardPhysical"
115
115
  :site-id="siteId"
116
+ :org-id="orgId"
116
117
  @replace="openReplaceDialog()"
117
118
  @delete="openDeleteDialog()"
119
+ @refresh="handleAssignRefresh"
118
120
  />
119
121
 
120
122
  <!-- Replace Card Dialog -->
@@ -304,6 +306,15 @@ function setCard({ mode = "create", dialog = true, data = {} as TCard } = {}) {
304
306
  }
305
307
  }
306
308
 
309
+ async function handleAssignRefresh() {
310
+ await getCards();
311
+ await nextTick();
312
+ statsRef.value?.refresh();
313
+ const updated = items.value.find((item: any) => item._id === selectedCard.value?._id);
314
+ if (updated) selectedCard.value = updated;
315
+ previewDialog.value = true;
316
+ }
317
+
307
318
  function successCreate() {
308
319
  createDialog.value = false;
309
320
  getCards();
@@ -228,7 +228,7 @@ import useSubscription from '../composables/useSubscription'
228
228
  type TabStatus = 'active' | 'suspended' | 'pending'
229
229
 
230
230
  /* ── COMPOSABLES ── */
231
- const { getAllV2 } = useOrg()
231
+ const { getOrganizationsWithSubscription } = useOrg()
232
232
  const { getVerifications, cancelUserInvitation } = useVerification()
233
233
  const { getCustomerSites: getCustomerSitesByOrgId } = useCustomerSite()
234
234
  const { getByOrgId: getSubscriptionByOrgId } = useSubscription()
@@ -250,7 +250,7 @@ const APP_LIST = [
250
250
  const tab = ref<TabStatus>('active')
251
251
  const dialog = ref(false)
252
252
  const openMenuId = ref<string | null>(null)
253
- // filterStatus sync với tab, dùng để filter + đổi tab qua select
253
+ // filterStatus
254
254
  const filterStatus = ref<'active' | 'suspended'>('active')
255
255
  const filterPlan = ref('') // '' = All | any APP_LIST value
256
256
  const filterBilling = ref('') // '' = All | 'monthly' | 'yearly'
@@ -303,60 +303,72 @@ function parseTotalItems(pageRangeStr: string, fallback: number): number {
303
303
  return match ? Number(match[1]) : fallback
304
304
  }
305
305
 
306
- /* ── FETCH ── */
307
- async function mapOrg(org: any, status: string) {
308
- let sitesCount = 0, planType = '-', billingCycle = '-',
309
- subscriptionStart = '-', subscriptionEnd = '-'
310
- try {
311
- const siteRes = await getCustomerSitesByOrgId(org._id)
312
- sitesCount = siteRes?.data?.totalItems
313
- ?? siteRes?.totalItems
314
- ?? siteRes?.data?.items?.length
315
- ?? siteRes?.items?.length
316
- ?? 0
317
-
318
- const subRes = await getSubscriptionByOrgId(org._id)
319
- const sub = subRes?.data ?? subRes
320
- if (sub) {
321
- planType = planTypeLabel(sub)
322
- billingCycle = sub?.billingCycle ?? '-'
323
- subscriptionStart = formatDate(sub?.createdAt)
324
- subscriptionEnd = formatDate(sub?.nextBillingDate)
325
- }
326
- } catch (e) {
327
- console.error('mapOrg error:', org._id, e)
328
- }
329
- return {
330
- _id: `${org._id}-${page.value}`,
331
- ...org,
332
- sites: `${sitesCount} Sites`,
333
- planType,
334
- billingCycle,
335
- subscriptionStart,
336
- subscriptionEnd,
337
- status: org?.status ?? status,
338
- }
339
- }
340
306
 
341
307
  async function fetchOrgs(status: 'active' | 'suspended') {
342
- const res = await getAllV2({
343
- page: page.value,
344
- search: search.value,
345
- limit: limit.value,
346
- status,
347
- // truyền filter xuống API nếu backend hỗ trợ
348
- ...(filterPlan.value ? { app: filterPlan.value } : {}),
349
- ...(filterBilling.value ? { billingCycle: filterBilling.value } : {}),
350
- })
308
+ loading.value = true
309
+
310
+ try {
311
+ const res = await getOrganizationsWithSubscription({
312
+ page: page.value,
313
+ search: search.value,
314
+ limit: limit.value,
315
+ status,
316
+ type: filterPlan.value,
317
+ billingCycle: filterBilling.value,
318
+ })
351
319
 
352
- const orgs = res?.data?.items ?? res?.items ?? []
353
- const mapped = await Promise.all(orgs.map((org: any) => mapOrg(org, status)))
320
+ const orgs = res?.data?.items ?? res?.items ?? []
354
321
 
355
- items.value = []
356
- await nextTick()
357
- items.value = [...mapped]
358
- pages.value = res?.pages ?? 1
359
- totalItems.value = parseTotalItems(res?.pageRange, mapped.length)
322
+ const siteCache: Record<string, number> = {}
323
+
324
+ items.value = await Promise.all(
325
+ orgs.map(async (org: any) => {
326
+ const sub = org?.subscription ?? {}
327
+
328
+ let siteCount = siteCache[org._id]
329
+
330
+ if (siteCount === undefined) {
331
+ const sitesRes = await getCustomerSitesByOrgId(org._id)
332
+
333
+ siteCount =
334
+ sitesRes?.items?.length ??
335
+ sitesRes?.data?.items?.length ??
336
+ 0
337
+
338
+ siteCache[org._id] = siteCount
339
+ }
340
+
341
+ return {
342
+ _id: org._id,
343
+
344
+ name: org.name,
345
+ email: org.email,
346
+ nature: org.nature,
347
+
348
+ sites: `${siteCount} Sites`,
349
+
350
+ planType: planTypeLabel(sub),
351
+
352
+ billingCycle: sub?.billingCycle ?? "-",
353
+
354
+ subscriptionStart: formatDate(sub?.createdAt),
355
+
356
+ subscriptionEnd: formatDate(sub?.nextBillingDate),
357
+
358
+ status: org?.status ?? status,
359
+ }
360
+ })
361
+ )
362
+
363
+ pages.value = res?.pages ?? 1
364
+
365
+ totalItems.value = parseTotalItems(
366
+ res?.pageRange,
367
+ items.value.length
368
+ )
369
+ } finally {
370
+ loading.value = false
371
+ }
360
372
  }
361
373
 
362
374
  async function fetchPending() {
@@ -364,7 +376,8 @@ async function fetchPending() {
364
376
  status: 'pending',
365
377
  type: 'user-invite',
366
378
  page: page.value,
367
- search: search.value, // search hoạt động đúng ở pending
379
+ search: search.value,
380
+ email: search.value,
368
381
  })
369
382
 
370
383
  items.value = await Promise.all(
@@ -391,7 +404,7 @@ async function fetchData() {
391
404
  }
392
405
  }
393
406
 
394
- /* ── WATCH: filterStatus select đổi → sync tab ── */
407
+ /* ── WATCH: filterStatus ── */
395
408
  watch(filterStatus, (val) => {
396
409
  tab.value = val
397
410
  page.value = 1
@@ -406,7 +419,7 @@ async function handleUpdatePage(p: number) {
406
419
 
407
420
  async function onTabChange(value: TabStatus) {
408
421
  tab.value = value
409
- // sync filterStatus khi click tab (chỉ 2 giá trị active/suspended)
422
+ // sync filterStatus ( active/suspended)
410
423
  if (value !== 'pending') filterStatus.value = value
411
424
  page.value = 1
412
425
  await fetchData()
@@ -421,13 +434,12 @@ function handleSearch() {
421
434
  if (searchTimeout) clearTimeout(searchTimeout)
422
435
  searchTimeout = setTimeout(() => {
423
436
  page.value = 1
424
- fetchData() // dùng search.value trực tiếp, không truyền param
437
+ fetchData()
425
438
  }, 300)
426
439
  }
427
440
 
428
441
  function handleFilterChange() {
429
- // filterStatus watch sẽ tự trigger fetchData nếu status đổi
430
- // nếu chỉ plan/billing thay đổi thì gọi thủ công
442
+
431
443
  page.value = 1
432
444
  fetchData()
433
445
  }
@@ -15,8 +15,8 @@
15
15
  v-model="document.attachment"
16
16
  :multiple="false"
17
17
  :max-length="10"
18
- title="Upload PDF Files"
19
- accept=".pdf, .doc, .docx, .xls, .xlsx, .txt"
18
+ title="Upload Files"
19
+ accept=".pdf,.csv,.doc,.docx"
20
20
  />
21
21
  </v-col>
22
22
 
@@ -28,15 +28,6 @@
28
28
  ></v-text-field>
29
29
  </v-col>
30
30
 
31
- <v-col cols="12" class="px-6">
32
- <InputLabel class="text-capitalize" title="Document Type" />
33
- <v-select
34
- v-model="document.type"
35
- :items="documentTypes"
36
- density="comfortable"
37
- />
38
- </v-col>
39
-
40
31
  <v-col cols="12">
41
32
  <v-row no-gutters>
42
33
  <v-col cols="12" class="text-center">
@@ -97,6 +88,10 @@ const prop = defineProps({
97
88
  type: String,
98
89
  default: "add",
99
90
  },
91
+ parentId: {
92
+ type: String,
93
+ default: "",
94
+ },
100
95
  document: {
101
96
  type: Object as PropType<TDocument>,
102
97
  default: () => ({
@@ -116,8 +111,6 @@ const validForm = ref(false);
116
111
  const disable = ref(false);
117
112
  const message = ref("");
118
113
 
119
- const documentTypes = ref(["Online Form", "Other"]);
120
-
121
114
  const document = ref<Record<string, any>>({
122
115
  name: "",
123
116
  attachment: [],
@@ -127,6 +120,10 @@ const document = ref<Record<string, any>>({
127
120
  const route = useRoute();
128
121
  const siteId = route.params.site as string;
129
122
  const orgId = route.params.org as string;
123
+ const routeParentId = computed(() =>
124
+ typeof route.query.parentId === "string" ? route.query.parentId : ""
125
+ );
126
+ const effectiveParentId = computed(() => prop.parentId || routeParentId.value);
130
127
 
131
128
  if (prop.mode === "edit") {
132
129
  document.value.name = prop.document.name;
@@ -138,7 +135,8 @@ watch(
138
135
  () => document.value.attachment,
139
136
  async (newVal) => {
140
137
  if (newVal && prop.mode !== "edit") {
141
- const fileData = await getFileById(newVal);
138
+ const fileId = Array.isArray(newVal) ? newVal[0] : newVal;
139
+ const fileData = await getFileById(fileId);
142
140
  if (fileData) {
143
141
  document.value.name = fileData.data.name;
144
142
  }
@@ -146,6 +144,14 @@ watch(
146
144
  }
147
145
  );
148
146
 
147
+ function deriveTypeFromFilename(name = "") {
148
+ const n = String(name).toLowerCase();
149
+ if (n.endsWith(".pdf")) return "pdf";
150
+ if (n.endsWith(".csv")) return "csv";
151
+ if (n.endsWith(".doc") || n.endsWith(".docx")) return "doc";
152
+ return "";
153
+ }
154
+
149
155
  function cancel() {
150
156
  // createMore.value = false;
151
157
  message.value = "";
@@ -156,23 +162,48 @@ async function submit() {
156
162
  disable.value = true;
157
163
  try {
158
164
  if (prop.mode === "add") {
165
+ const fileId = Array.isArray(document.value.attachment)
166
+ ? document.value.attachment[0]
167
+ : document.value.attachment;
168
+
169
+ const fileData = await getFileById(fileId);
170
+ const fileName = fileData?.data?.name || String(fileId || "");
171
+ const derivedType = deriveTypeFromFilename(fileName);
172
+
173
+ if (!derivedType) {
174
+ throw new Error("Unsupported file type. Allowed: pdf, csv, doc.");
175
+ }
176
+
159
177
  const payload = {
160
178
  name: document.value.name,
161
179
  attachment: document.value.attachment,
162
- type: document.value.type || "Other",
180
+ type: derivedType,
163
181
  status: "active",
164
182
  org: orgId,
165
183
  site: siteId,
184
+ parentId: effectiveParentId.value,
166
185
  };
186
+
167
187
  await add(payload);
168
188
  }
169
189
 
170
190
  if (prop.mode === "edit") {
171
- const payload = {
191
+ const fileId = Array.isArray(document.value.attachment)
192
+ ? document.value.attachment[0]
193
+ : document.value.attachment;
194
+
195
+ let payload: Record<string, any> = {
172
196
  name: document.value.name,
173
197
  attachment: document.value.attachment,
174
- type: document.value.type,
175
198
  };
199
+
200
+ if (fileId) {
201
+ const fileData = await getFileById(fileId);
202
+ const fileName = fileData?.data?.name || String(fileId || "");
203
+ const derivedType = deriveTypeFromFilename(fileName);
204
+ if (derivedType) payload.type = derivedType;
205
+ }
206
+
176
207
  await updateById(prop.document._id ?? "", payload);
177
208
  }
178
209
 
@@ -2,7 +2,6 @@
2
2
  <v-row no-gutters class="pa-0">
3
3
  <v-col cols="12" class="mb-2">
4
4
  <v-row no-gutters align="center">
5
- <!-- Left Buttons -->
6
5
  <v-col cols="12" md="6" class="d-flex align-center">
7
6
  <v-btn
8
7
  class="text-none me-2"
@@ -21,19 +20,17 @@
21
20
  variant="tonal"
22
21
  size="large"
23
22
  @click="openCreateFolderDialog()"
24
- v-if="canCreate && canCreateDocument"
23
+ v-if="canCreate && canCreateFolder"
25
24
  >
26
25
  Create Folder
27
26
  </v-btn>
28
27
  </v-col>
29
28
 
30
- <!-- Right Search + Filter -->
31
29
  <v-col
32
30
  cols="12"
33
31
  md="6"
34
32
  class="d-flex justify-end align-center mt-4 mt-md-0"
35
33
  >
36
- <!-- Search -->
37
34
  <v-text-field
38
35
  v-model="search"
39
36
  placeholder="Search"
@@ -54,7 +51,7 @@
54
51
  class="filtertype-btn text-none"
55
52
  style="max-width: 220px"
56
53
  >
57
- {{ selectedFileType || 'Filter by File Type' }}
54
+ {{ selectedFileType || "Filter by File Type" }}
58
55
  </v-btn>
59
56
  </template>
60
57
 
@@ -76,6 +73,22 @@
76
73
  </v-col>
77
74
  </v-row>
78
75
  </v-col>
76
+
77
+ <v-col cols="12" class="mb-2" v-if="isInsideFolder">
78
+ <div class="folder-path-bar d-flex align-center ga-2 px-3 py-2">
79
+ <v-btn
80
+ icon="mdi-arrow-left"
81
+ size="small"
82
+ variant="text"
83
+ @click="goBackFolder"
84
+ />
85
+ <v-icon size="18" color="grey-darken-2">mdi-folder</v-icon>
86
+ <span class="text-body-2 font-weight-medium">{{
87
+ currentFolderPath
88
+ }}</span>
89
+ </div>
90
+ </v-col>
91
+
79
92
  <v-col cols="12">
80
93
  <TableV3
81
94
  :headers="headers"
@@ -89,11 +102,49 @@
89
102
  @update:page="page = $event"
90
103
  @row-click="tableRowClickHandler"
91
104
  >
105
+ <template v-if="isInsideFolder" #breadcrumb>
106
+ <div class="d-flex align-center flex-wrap ga-1">
107
+ <v-btn
108
+ icon="mdi-chevron-left"
109
+ size="x-small"
110
+ variant="text"
111
+ density="comfortable"
112
+ class="me-1"
113
+ @click="goBackFolder"
114
+ />
115
+
116
+ <span
117
+ class="breadcrumb-segment text-body-2 text-grey-darken-1 cursor-pointer"
118
+ @click="navigateToRoot"
119
+ >Home</span
120
+ >
121
+ <template v-for="(folder, idx) in folderStack" :key="folder.id">
122
+ <v-icon size="14" color="grey-darken-1">mdi-chevron-right</v-icon>
123
+ <span
124
+ :class="[
125
+ 'breadcrumb-segment text-body-2 font-weight-medium',
126
+ idx === folderStack.length - 1
127
+ ? 'text-black'
128
+ : 'text-grey-darken-1 cursor-pointer',
129
+ ]"
130
+ @click="
131
+ idx < folderStack.length - 1 && navigateToStackIndex(idx)
132
+ "
133
+ >{{ folder.name }}</span
134
+ >
135
+ </template>
136
+ </div>
137
+ </template>
92
138
  <template #item.name="{ item }">
93
139
  <div class="d-flex align-center ga-2">
94
- <v-icon size="18" color="grey-darken-2">
140
+ <v-icon
141
+ size="18"
142
+ :color="
143
+ isFolderItem(item._raw) ? 'amber-darken-2' : 'grey-darken-2'
144
+ "
145
+ >
95
146
  {{
96
- item.file_type?.toLowerCase() === "folder"
147
+ isFolderItem(item._raw)
97
148
  ? "mdi-folder"
98
149
  : "mdi-file-document-outline"
99
150
  }}
@@ -115,6 +166,7 @@
115
166
  variant="flat"
116
167
  class="table-download-btn text-none"
117
168
  @click.stop="downloadDocument(item._raw)"
169
+ :disabled="isFolderItem(item._raw)"
118
170
  >
119
171
  Download
120
172
  </v-btn>
@@ -124,7 +176,11 @@
124
176
 
125
177
  <!-- Create Dialog -->
126
178
  <v-dialog v-model="createDialog" width="450" persistent>
127
- <DocumentForm @cancel="createDialog = false" @success="successCreate()" />
179
+ <DocumentForm
180
+ :parent-id="activeParentId"
181
+ @cancel="createDialog = false"
182
+ @success="successCreate()"
183
+ />
128
184
  </v-dialog>
129
185
 
130
186
  <!-- Create Folder Dialog -->
@@ -145,7 +201,7 @@
145
201
  <v-text-field
146
202
  v-model.trim="folderTitle"
147
203
  density="comfortable"
148
- :rules="[v => !!v || 'Folder title is required']"
204
+ :rules="[(v) => !!v || 'Folder title is required']"
149
205
  />
150
206
  </v-col>
151
207
 
@@ -394,6 +450,10 @@ const props = defineProps({
394
450
  type: Boolean,
395
451
  default: true,
396
452
  },
453
+ canCreateFolder: {
454
+ type: Boolean,
455
+ default: true,
456
+ },
397
457
  canUpdateDocument: {
398
458
  type: Boolean,
399
459
  default: true,
@@ -406,9 +466,17 @@ const props = defineProps({
406
466
 
407
467
  const { headerSearch } = useLocal();
408
468
  const documentService = useDocument();
409
- const { getAll: _getAllDocuments, deleteById, createFolder, create } = documentService;
469
+ const {
470
+ getAll: _getAllDocuments,
471
+ deleteById,
472
+ createFolder,
473
+ create,
474
+ } = documentService;
410
475
  const { getFileUrl } = useFile();
411
476
 
477
+ const router = useRouter();
478
+ const route = useRoute();
479
+
412
480
  const page = ref(1);
413
481
  const pages = ref(0);
414
482
  const pageRange = ref("-- - -- of --");
@@ -419,8 +487,29 @@ const messageColor = ref("");
419
487
 
420
488
  const items = ref<Array<Record<string, any>>>([]);
421
489
 
422
- const route = useRoute();
423
490
  const siteId = route.params.site as string;
491
+ const orgId = route.params.org as string;
492
+ const routeParentId = computed(() =>
493
+ typeof route.query.parentId === "string" ? route.query.parentId : ""
494
+ );
495
+ const activeParentId = ref(routeParentId.value);
496
+ const folderStack = ref<Array<{ id: string; name: string }>>([]);
497
+
498
+ const search = computed({
499
+ get: () => headerSearch.value,
500
+ set: (value: string) => {
501
+ headerSearch.value = value;
502
+ },
503
+ });
504
+
505
+ const isInsideFolder = computed(() => activeParentId.value !== "");
506
+ const currentFolderPath = computed(() => {
507
+ if (folderStack.value.length === 0 && activeParentId.value) {
508
+ return "Current Folder";
509
+ }
510
+
511
+ return folderStack.value.map((folder) => folder.name).join(" / ");
512
+ });
424
513
 
425
514
  const {
426
515
  data: getDocumentReq,
@@ -433,9 +522,10 @@ const {
433
522
  page: page.value,
434
523
  search: headerSearch.value,
435
524
  site: siteId,
525
+ parentId: activeParentId.value,
436
526
  }),
437
527
  {
438
- watch: [page, headerSearch],
528
+ watch: [page, headerSearch, activeParentId],
439
529
  }
440
530
  );
441
531
 
@@ -473,8 +563,8 @@ const tableItems = computed(() => {
473
563
  return items.value.map((doc: any) => ({
474
564
  ...doc,
475
565
  _raw: doc,
476
- file_type: doc.type || "N/A",
477
- file_size: doc.file_size || doc.size || "0mb",
566
+ file_type: doc.type === "folder" ? "Folder" : doc.type || "N/A",
567
+ file_size: doc.type === "folder" ? "—" : doc.file_size || doc.size || "0mb",
478
568
  date_created: formatDate(doc.created_at || doc.createdAt),
479
569
  date_modified: formatDate(doc.updated_at || doc.updatedAt),
480
570
  remarks: doc.remarks || "N/A",
@@ -506,10 +596,51 @@ const confirmDialog = ref(false);
506
596
  const selectedDocumentId = ref<string | null>(null);
507
597
 
508
598
  function tableRowClickHandler(data: any) {
509
- selectedDocument.value = (data?._raw || data) as TDocument;
599
+ const rowData = (data?._raw || data) as TDocument;
600
+
601
+ if (isFolderItem(rowData)) {
602
+ openFolder(rowData);
603
+ return;
604
+ }
605
+
606
+ selectedDocument.value = rowData;
510
607
  previewDialog.value = true;
511
608
  }
512
609
 
610
+ function isFolderItem(document?: Partial<TDocument> & Record<string, any>) {
611
+ if (!document) {
612
+ return false;
613
+ }
614
+
615
+ const docType = String(
616
+ document.type || document.file_type || ""
617
+ ).toLowerCase();
618
+ return docType === "folder";
619
+ }
620
+
621
+ function openFolder(document: Partial<TDocument> & Record<string, any>) {
622
+ const folderId = String(document._id || "");
623
+ const folderName = String(document.name || "Folder");
624
+
625
+ if (!folderId) {
626
+ return;
627
+ }
628
+
629
+ activeParentId.value = folderId;
630
+ folderStack.value.push({ id: folderId, name: folderName });
631
+ page.value = 1;
632
+ }
633
+
634
+ function goBackFolder() {
635
+ if (folderStack.value.length === 0) {
636
+ return;
637
+ }
638
+
639
+ folderStack.value.pop();
640
+ activeParentId.value = folderStack.value.at(-1)?.id || "";
641
+ page.value = 1;
642
+ }
643
+
513
644
  function downloadDocument(document?: TDocument) {
514
645
  if (!document?.attachment || !process.client) {
515
646
  return;
@@ -563,24 +694,28 @@ async function handleCreateFolder() {
563
694
 
564
695
  folderLoading.value = true;
565
696
  try {
566
- if (documentService.createFolder) {
567
- const resp = await (documentService as any).createFolder({
568
- name: folderTitle.value,
569
- remarks: folderRemarks.value,
570
- site: siteId,
571
- });
572
- showMessage(resp?.message || "Folder created successfully!", "success");
697
+ const payload = {
698
+ name: folderTitle.value,
699
+ remarks: folderRemarks.value,
700
+ org: orgId,
701
+ site: siteId,
702
+ parentId: activeParentId.value,
703
+ type: "folder",
704
+ };
705
+
706
+ let resp: any = null;
707
+ if ((documentService as any).createFolder) {
708
+ resp = await (documentService as any).createFolder(payload);
573
709
  } else if ((documentService as any).create) {
574
- const resp = await (documentService as any).create({
575
- name: folderTitle.value,
576
- remarks: folderRemarks.value,
577
- type: "folder",
578
- site: siteId,
579
- });
580
- showMessage(resp?.message || "Folder created successfully!", "success");
710
+ resp = await (documentService as any).create(payload);
711
+ } else if ((documentService as any).add) {
712
+ resp = await (documentService as any).add(payload);
581
713
  } else {
582
- // fallback: no API available in this service
583
- showMessage("Folder created (local)", "success");
714
+ showMessage("Unable to create folder. No API available.", "error");
715
+ }
716
+
717
+ if (resp) {
718
+ showMessage(resp?.message || "Folder created successfully!", "success");
584
719
  }
585
720
 
586
721
  createFolderDialog.value = false;
@@ -588,7 +723,10 @@ async function handleCreateFolder() {
588
723
  folderRemarks.value = "";
589
724
  await getDocuments();
590
725
  } catch (error: any) {
591
- showMessage(error?.response?._data?.message || "Failed to create folder", "error");
726
+ showMessage(
727
+ error?.response?._data?.message || "Failed to create folder",
728
+ "error"
729
+ );
592
730
  } finally {
593
731
  folderLoading.value = false;
594
732
  }
@@ -670,4 +808,10 @@ function selectFileType(type: string) {
670
808
  .filtertype-item:last-child {
671
809
  border-bottom: none;
672
810
  }
811
+
812
+ .folder-path-bar {
813
+ border: 1px solid #e0e0e0;
814
+ border-radius: 8px;
815
+ background: #fafafa;
816
+ }
673
817
  </style>
@@ -74,7 +74,7 @@
74
74
  </v-row>
75
75
  </v-col>
76
76
 
77
- <v-col cols="12" class="mt-2">
77
+ <v-col v-if="!props.hideCreateMore" cols="12" class="mt-2">
78
78
  <v-checkbox v-model="createMore" density="comfortable" hide-details>
79
79
  <template #label>
80
80
  <span class="text-subtitle-2 font-weight-bold">
@@ -194,7 +194,11 @@ const props = defineProps({
194
194
  inviteMember: {
195
195
  type: Boolean,
196
196
  default: false
197
- }
197
+ },
198
+ hideCreateMore: {
199
+ type: Boolean,
200
+ default: false
201
+ }
198
202
  });
199
203
 
200
204
  const emit = defineEmits(["cancel", "success", "success:create-more"]);
@@ -221,8 +221,7 @@ const { requiredRule } = useUtils();
221
221
  const { createRole } = useRole();
222
222
  const { getSiteById } = useSite();
223
223
 
224
- // Gom selected + enabled vào 1 shallowRef duy nhất
225
- // để chỉ trigger 1 render cycle khi update cả 2
224
+ // All selected + enabled 1 shallowRef
226
225
  const permState = shallowRef({
227
226
  selected: [] as string[],
228
227
  enabled: [] as string[],
@@ -283,7 +282,7 @@ function toggleSelectAll(value: boolean) {
283
282
  }
284
283
  }
285
284
 
286
- // 1 assignment duy nhất = 1 render cycle, không crash
285
+ // 1 assignment to trigger reactivity only once
287
286
  permState.value = { selected: allKeys, enabled: categories }
288
287
  }
289
288
 
@@ -43,6 +43,10 @@
43
43
  </template>
44
44
  </v-toolbar>
45
45
 
46
+ <div v-if="$slots.breadcrumb" class="table-v3__breadcrumb">
47
+ <slot name="breadcrumb" />
48
+ </div>
49
+
46
50
  <v-data-table
47
51
  :headers="headers"
48
52
  :items="items"
@@ -176,4 +180,10 @@ watch(
176
180
  width: 38px;
177
181
  height: 38px;
178
182
  }
183
+
184
+ .table-v3__breadcrumb {
185
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
186
+ background-color: #fff;
187
+ padding: 6px 12px;
188
+ }
179
189
  </style>
@@ -299,7 +299,23 @@ export default function useAccessManagement() {
299
299
  );
300
300
  }
301
301
 
302
+ function getResidents(params: { orgId: string; siteId: string; unitId: string }) {
303
+ return useNuxtApp().$api<{ data: Record<string, any>[] }>(
304
+ `/api/access-management/residents`,
305
+ { method: "GET", query: params }
306
+ );
307
+ }
308
+
309
+ function assignUser(payload: { assignees: string[]; unit: string; type: string; acm_url: string }) {
310
+ return useNuxtApp().$api<Record<string, any>>(
311
+ `/api/access-management/assign-user`,
312
+ { method: "POST", body: payload }
313
+ );
314
+ }
315
+
302
316
  return {
317
+ getResidents,
318
+ assignUser,
303
319
  getDoorAccessLevels,
304
320
  getLiftAccessLevels,
305
321
  getAccessGroups,
@@ -5,6 +5,7 @@ export default function useDocument() {
5
5
  limit = 10,
6
6
  status = "active",
7
7
  site = "",
8
+ parentId = "",
8
9
  } = {}) {
9
10
  return useNuxtApp().$api<Record<string, any>>(`/api/documents`, {
10
11
  method: "GET",
@@ -14,6 +15,7 @@ export default function useDocument() {
14
15
  limit,
15
16
  status,
16
17
  site,
18
+ parentId,
17
19
  },
18
20
  });
19
21
  }
@@ -26,10 +28,13 @@ export default function useDocument() {
26
28
  }
27
29
 
28
30
  function deleteById(id: string) {
29
- return useNuxtApp().$api<Record<string, any>>(`/api/documents/deleted/document`, {
30
- method: "PUT",
31
- query: { id },
32
- });
31
+ return useNuxtApp().$api<Record<string, any>>(
32
+ `/api/documents/deleted/document`,
33
+ {
34
+ method: "PUT",
35
+ query: { id },
36
+ }
37
+ );
33
38
  }
34
39
 
35
40
  function updateById(id: string, value: any) {
@@ -39,6 +44,12 @@ export default function useDocument() {
39
44
  });
40
45
  }
41
46
 
47
+ function getById(id: string) {
48
+ return useNuxtApp().$api<Record<string, any>>(`/api/documents/${id}`, {
49
+ method: "GET",
50
+ });
51
+ }
52
+
42
53
  function getDocumentsBySiteId(siteId: string, type?: string) {
43
54
  return useNuxtApp().$api<Record<string, any>>(
44
55
  `/api/documents/site/${siteId}/type/${type}`,
@@ -48,11 +59,22 @@ export default function useDocument() {
48
59
  );
49
60
  }
50
61
 
62
+ function create(value: any) {
63
+ return add(value);
64
+ }
65
+
66
+ function createFolder(value: any) {
67
+ return add({ ...value, type: "folder" });
68
+ }
69
+
51
70
  return {
52
71
  getAll,
72
+ getById,
53
73
  add,
54
74
  deleteById,
55
75
  updateById,
56
76
  getDocumentsBySiteId,
77
+ create,
78
+ createFolder,
57
79
  };
58
80
  }
@@ -107,31 +107,6 @@ export default function useOrg() {
107
107
  });
108
108
  }
109
109
 
110
- /** Super Admin client list, etc.: filter by `organizations.status` (v1 API ignores status). */
111
- function getAllV2({
112
- page = 1,
113
- search = "",
114
- limit = 20,
115
- nature = "",
116
- status = "active",
117
- }: {
118
- page?: number;
119
- search?: string;
120
- limit?: number;
121
- nature?: string;
122
- status?: "active" | "suspended" | "deleted";
123
- } = {}) {
124
- return useNuxtApp().$api<Record<string, any>>("/api/organizations/v2", {
125
- method: "GET",
126
- query: {
127
- page,
128
- search,
129
- limit,
130
- nature,
131
- status,
132
- },
133
- });
134
- }
135
110
 
136
111
  function add(
137
112
  value: Partial<Pick<TOrg, "name" | "email" | "nature" | "contact">>
@@ -179,6 +154,64 @@ export default function useOrg() {
179
154
  }
180
155
  );
181
156
  }
157
+ function getAllV2({
158
+ page = 1,
159
+ search = "",
160
+ limit = 20,
161
+ nature = "",
162
+ status = "active",
163
+
164
+ }: {
165
+ page?: number;
166
+ search?: string;
167
+ limit?: number;
168
+ nature?: string;
169
+ status?: "active" | "suspended" | "deleted";
170
+ } = {}) {
171
+ return useNuxtApp().$api<Record<string, any>>("/api/organizations/v2", {
172
+ method: "GET",
173
+ query: {
174
+ page,
175
+ search,
176
+ limit,
177
+ nature,
178
+ status,
179
+ },
180
+ });
181
+ }
182
+
183
+ function getOrganizationsWithSubscription({
184
+ page = 1,
185
+ search = "",
186
+ limit = 20,
187
+ status = "active",
188
+ type = "",
189
+ billingCycle = "",
190
+ }: {
191
+ page?: number
192
+ search?: string
193
+ limit?: number
194
+ status?: "active" | "suspended" | "deleted"
195
+ type?: string
196
+ billingCycle?: string
197
+ } = {}) {
198
+ return useNuxtApp().$api<Record<string, any>>(
199
+ "/api/organizations/v2/orgs/subscriptions",
200
+ {
201
+ method: "GET",
202
+
203
+ query: {
204
+ page,
205
+ search,
206
+ limit,
207
+ status,
208
+ type,
209
+ billingCycle,
210
+ },
211
+ }
212
+ )
213
+ }
214
+
182
215
 
183
216
  return {
184
217
  org,
@@ -196,6 +229,7 @@ export default function useOrg() {
196
229
  add,
197
230
  addOnboardingOrg,
198
231
  updateOrg,
199
- getOrgsByEmail
232
+ getOrgsByEmail,
233
+ getOrganizationsWithSubscription
200
234
  };
201
235
  }
@@ -14,7 +14,18 @@ export default function () {
14
14
  } = {}) {
15
15
  return await useNuxtApp().$api<Record<string, any>>("/api/people", {
16
16
  method: "GET",
17
- query: { page, limit, sort, search, org, site, dateTo, dateFrom, type, status },
17
+ query: {
18
+ page,
19
+ limit,
20
+ sort,
21
+ search,
22
+ org,
23
+ site,
24
+ dateTo,
25
+ dateFrom,
26
+ type,
27
+ status,
28
+ },
18
29
  });
19
30
  }
20
31
 
@@ -26,12 +37,10 @@ export default function () {
26
37
  });
27
38
  }
28
39
 
29
- async function findPersonByNRICMultipleResult(
30
- nric: string, site: string
31
- ){
40
+ async function findPersonByNRICMultipleResult(nric: string, site: string) {
32
41
  return await $fetch<Record<any, any>>(`/api/people/all-nric`, {
33
42
  method: "GET",
34
- query: { nric, site }
43
+ query: { nric, site },
35
44
  });
36
45
  }
37
46
 
@@ -98,6 +107,15 @@ export default function () {
98
107
  }
99
108
  );
100
109
  }
110
+ async function reviewResidentPerson(
111
+ _id: string,
112
+ payload: Partial<TPeoplePayload>
113
+ ) {
114
+ return await useNuxtApp().$api<Record<string, any>>(`/api/people/${_id}`, {
115
+ method: "PATCH",
116
+ body: payload,
117
+ });
118
+ }
101
119
 
102
120
  return {
103
121
  create,
@@ -110,5 +128,6 @@ export default function () {
110
128
  getPeopleByUnit,
111
129
  searchCompanyList,
112
130
  findUsersByPlateNumber,
131
+ reviewResidentPerson,
113
132
  };
114
133
  }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "1.11.45",
5
+ "version": "1.11.47",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -35,4 +35,10 @@ declare type TCustomerSite = {
35
35
  }
36
36
  };
37
37
 
38
- declare type TSiteCategory = "residential" | "commercial" | "industrial" | "institutional" | "mixed_development" | "infrastructure" | "hospitality"
38
+ type TSiteCategory =
39
+ | "residential"
40
+ | "commercial"
41
+ | "shopping_mall"
42
+ | "mix_development"
43
+ | "industrial"
44
+ | "co_working_co_living";
package/types/people.d.ts CHANGED
@@ -25,6 +25,6 @@ declare type TPeople = {
25
25
  declare type TPlateNumber = { plateNumber: string, recNo: string, _id?: string, status: "active" | "deleted" | "pending", type: TVehicleType };
26
26
 
27
27
 
28
- declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner">
28
+ declare type TPeoplePayload = Pick<TGuest, "name" | "block" | "level" | "unit" | "unitName" | "contact" | "plateNumber" | "nric" | "contact" | "remarks" | "org" | "site" | "start" | "end" | "type" | "isOwner"> & { status?: string }
29
29
 
30
30
  declare type TPeopleType = "guest" | "resident" | "tenant"