@7365admin1/layer-common 1.11.46 → 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,12 @@
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
+
3
10
  ## 1.11.46
4
11
 
5
12
  ### 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"
@@ -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
@@ -395,10 +399,11 @@ const emit = defineEmits<{
395
399
  "update:selectedCardInUnit": [value: Record<string, any> | null];
396
400
  replace: [];
397
401
  delete: [];
402
+ refresh: [];
398
403
  "assign-to-person": [{ card: Record<string, any> | null; person: Record<string, any> | null }];
399
404
  }>();
400
405
 
401
- const { getResidents } = useAccessManagement();
406
+ const { getResidents, assignUser } = useAccessManagement();
402
407
 
403
408
  const historyDialog = ref(false);
404
409
  const showAssignForm = ref(false);
@@ -406,6 +411,18 @@ const assignPersonType = ref<string>("");
406
411
  const assignPerson = ref<Record<string, any> | null>(null);
407
412
  const peopleItems = ref<Record<string, any>[]>([]);
408
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
+ }
409
426
 
410
427
  const isSelectedCardAvailable = computed(() => {
411
428
  if (!props.selectedCardInUnit?._id) return false;
@@ -444,13 +461,19 @@ async function openAssignForm() {
444
461
  showAssignForm.value = true;
445
462
  assignPerson.value = null;
446
463
  assignPersonType.value = "";
464
+ assignError.value = "";
447
465
  peopleItems.value = [];
466
+ encryptedAcmUrl.value = "";
448
467
 
449
468
  if (!props.unit?._id || !props.orgId || !props.siteId) return;
450
469
  peopleLoading.value = true;
451
470
  try {
452
- const res = await getResidents({ orgId: props.orgId, siteId: props.siteId, unitId: props.unit._id });
453
- 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 ?? "";
454
477
  } catch {
455
478
  peopleItems.value = [];
456
479
  } finally {
@@ -462,14 +485,44 @@ function cancelAssign() {
462
485
  showAssignForm.value = false;
463
486
  assignPerson.value = null;
464
487
  assignPersonType.value = "";
488
+ assignError.value = "";
465
489
  }
466
490
 
467
- function confirmAssign() {
468
- emit("assign-to-person", {
469
- card: props.selectedCardInUnit,
470
- person: assignPerson.value,
471
- });
472
- 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
+ }
473
526
  }
474
527
 
475
528
  function toggleCard(card: Record<string, any>) {
@@ -116,6 +116,7 @@
116
116
  :org-id="orgId"
117
117
  @replace="openReplaceDialog()"
118
118
  @delete="openDeleteDialog()"
119
+ @refresh="handleAssignRefresh"
119
120
  />
120
121
 
121
122
  <!-- Replace Card Dialog -->
@@ -305,6 +306,15 @@ function setCard({ mode = "create", dialog = true, data = {} as TCard } = {}) {
305
306
  }
306
307
  }
307
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
+
308
318
  function successCreate() {
309
319
  createDialog.value = false;
310
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>
@@ -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>
@@ -306,8 +306,16 @@ export default function useAccessManagement() {
306
306
  );
307
307
  }
308
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
+
309
316
  return {
310
317
  getResidents,
318
+ assignUser,
311
319
  getDoorAccessLevels,
312
320
  getLiftAccessLevels,
313
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
  }
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.46",
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";