@7365admin1/layer-common 4.97.0 → 4.97.1-staging.500
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 +4 -4
- package/assets/css/primitives.css +57 -0
- package/assets/css/screens.css +8 -4
- package/components/AccessCardDetailsDialog.vue +59 -6
- package/components/AccessCardPreviewDialog.vue +371 -53
- package/components/AppSelect.vue +143 -4
- package/components/BuildingUnitFormEdit.vue +6 -0
- package/components/BuildingUnitManagement.vue +298 -0
- package/components/Dialog/UpdateMoreAction.vue +11 -1
- package/components/DocumentForm.vue +407 -112
- package/components/DocumentManagement.vue +213 -12
- package/components/Facility/BookingSetup.vue +63 -0
- package/components/HidAccessLogDashboard.vue +74 -17
- package/components/HidAccessPermissions.vue +424 -0
- package/components/HidIntercomManagement.vue +183 -14
- package/components/HidProfileQrCode.vue +332 -0
- package/components/HidQrCodeConfiguration.vue +17 -195
- package/components/HidReaderManagement.vue +398 -0
- package/components/HidReaderUserRoster.vue +44 -3
- package/components/HidUserEnrollment.vue +2239 -276
- package/components/InvitationClientForm.vue +19 -1
- package/components/Nfc/NFCPatrolRouteForm.vue +109 -58
- package/components/Nfc/NFCPatrolRouteMain.vue +3 -18
- package/components/Nfc/PatrolReport/PatrolActivityTable.vue +0 -8
- package/components/Nfc/PatrolReport/PatrolRouteSummary.vue +1 -1
- package/components/ServiceProviderMain.vue +48 -0
- package/components/SiteProviderChange.vue +371 -0
- package/components/TableMain.vue +22 -7
- package/components/VisitorForm.vue +12 -0
- package/components/VisitorManagement.vue +267 -5
- package/composables/useAccessManagement.ts +17 -1
- package/composables/useCustomerSite.ts +29 -0
- package/composables/useDocument.ts +7 -0
- package/composables/useFacility.ts +11 -0
- package/composables/useHidAmico.ts +165 -0
- package/composables/useHidNavigation.ts +25 -1
- package/composables/useHidReaderSelection.ts +50 -0
- package/composables/useMember.ts +12 -0
- package/composables/useNFCPatrolReport.ts +36 -32
- package/composables/useNFCPatrolReportFilters.ts +21 -3
- package/composables/useNFCPatrolRoute.ts +10 -3
- package/composables/usePeople.ts +19 -0
- package/composables/useServiceProvider.ts +45 -2
- package/composables/useSiteCategory.ts +45 -0
- package/package.json +1 -1
- package/pages/[org]/[site]/access-mgmt/administrator/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-cards/index.vue +1 -1
- package/pages/[org]/[site]/access-mgmt/hid-qr-code/index.vue +23 -0
- package/pages/[org]/[site]/access-mgmt/hid-readers/index.vue +1 -1
- package/types/document.d.ts +14 -0
- package/types/facility.d.ts +4 -0
- package/types/member.d.ts +4 -0
- package/types/nfc-patrol-report.ts +0 -2
- package/types/people.d.ts +28 -1
- package/types/service-provider.d.ts +5 -0
- package/types/site.d.ts +7 -1
- package/utils/hid-enrolment-subject.ts +344 -0
- package/utils/hid-permission-assignments.ts +198 -0
- package/utils/hid-reader-selection.ts +52 -0
- package/utils/nfc-patrol-time.ts +99 -0
- package/utils/occupancy-role.ts +109 -0
- package/utils/service-type.ts +30 -0
|
@@ -1,45 +1,177 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-card width="100%" class="screen-modal">
|
|
3
|
-
<v-card-title class="
|
|
3
|
+
<v-card-title class="screen-modal__title">{{ heading }}</v-card-title>
|
|
4
4
|
|
|
5
5
|
<v-card-text class="screen-modal__body pa-0">
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
<!--
|
|
7
|
+
EDIT: metadata only. The design deliberately shows the attachment as a
|
|
8
|
+
read-only chip here - swapping the file is what Update- Document is for,
|
|
9
|
+
and expiry is only set on upload or renewal, so neither control appears.
|
|
10
|
+
-->
|
|
11
|
+
<template v-if="isEdit">
|
|
12
|
+
<v-col cols="12" class="px-6 pt-4">
|
|
13
|
+
<InputLabel title="Document Attachment" />
|
|
14
|
+
<div class="doc-form__chip">
|
|
15
|
+
<v-icon size="20" class="mr-2">mdi-file-document-outline</v-icon>
|
|
16
|
+
<span class="text-body-2">{{ existingFileName || "—" }}</span>
|
|
17
|
+
</div>
|
|
16
18
|
</v-col>
|
|
17
19
|
|
|
18
20
|
<v-col cols="12" class="px-6">
|
|
19
|
-
<InputLabel
|
|
21
|
+
<InputLabel title="Document Title" required />
|
|
20
22
|
<v-text-field
|
|
21
|
-
v-model.trim="
|
|
23
|
+
v-model.trim="editTitle"
|
|
22
24
|
class="filter-field"
|
|
23
25
|
density="comfortable"
|
|
24
|
-
|
|
26
|
+
placeholder="Company Policy"
|
|
27
|
+
hide-details="auto"
|
|
28
|
+
/>
|
|
25
29
|
</v-col>
|
|
26
30
|
|
|
27
|
-
<v-col cols="12">
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
<v-col cols="12" class="px-6 pb-2">
|
|
32
|
+
<InputLabel title="Remarks (Optional)" />
|
|
33
|
+
<v-textarea
|
|
34
|
+
v-model.trim="editRemarks"
|
|
35
|
+
class="filter-field"
|
|
36
|
+
density="comfortable"
|
|
37
|
+
rows="3"
|
|
38
|
+
placeholder="Enter remarks"
|
|
39
|
+
hide-details="auto"
|
|
40
|
+
/>
|
|
37
41
|
</v-col>
|
|
38
|
-
</
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<!-- ADD and UPDATE: a drop zone, then one configurable row per file. -->
|
|
45
|
+
<template v-else>
|
|
46
|
+
<v-col cols="12" class="px-6 pt-4">
|
|
47
|
+
<InputLabel title="Document Attachment" />
|
|
48
|
+
|
|
49
|
+
<!--
|
|
50
|
+
Hand-rolled rather than `InputFileV2`, which renders its own chip
|
|
51
|
+
list from internal state and only ever pushes ids outward. The
|
|
52
|
+
design puts that list in the table below with a per-row delete, and
|
|
53
|
+
a delete there could not reach back into the uploader to drop the
|
|
54
|
+
chip - the two lists would disagree on what is about to be saved.
|
|
55
|
+
-->
|
|
56
|
+
<div
|
|
57
|
+
class="doc-form__drop"
|
|
58
|
+
:class="{ 'doc-form__drop--over': dragging }"
|
|
59
|
+
role="button"
|
|
60
|
+
tabindex="0"
|
|
61
|
+
@click="pickFiles"
|
|
62
|
+
@keydown.enter.prevent="pickFiles"
|
|
63
|
+
@keydown.space.prevent="pickFiles"
|
|
64
|
+
@dragover.prevent="dragging = true"
|
|
65
|
+
@dragleave.prevent="dragging = false"
|
|
66
|
+
@drop.prevent="onDrop"
|
|
67
|
+
>
|
|
68
|
+
<v-progress-circular v-if="uploading" indeterminate size="24" />
|
|
69
|
+
<template v-else>
|
|
70
|
+
<v-icon size="24" class="mr-2">mdi-cloud-upload-outline</v-icon>
|
|
71
|
+
<span class="text-body-2">{{ dropLabel }}</span>
|
|
72
|
+
</template>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<input
|
|
76
|
+
ref="fileInput"
|
|
77
|
+
type="file"
|
|
78
|
+
class="d-none"
|
|
79
|
+
:accept="ACCEPT"
|
|
80
|
+
:multiple="isAdd"
|
|
81
|
+
@change="onPick"
|
|
82
|
+
/>
|
|
83
|
+
</v-col>
|
|
84
|
+
|
|
85
|
+
<v-col v-if="rows.length" cols="12" class="px-6 pb-2">
|
|
86
|
+
<div class="d-flex align-center justify-space-between">
|
|
87
|
+
<span class="text-body-2">{{ selectionSummary }}</span>
|
|
88
|
+
<v-btn
|
|
89
|
+
v-if="isAdd"
|
|
90
|
+
variant="flat"
|
|
91
|
+
class="text-none screen-btn-primary"
|
|
92
|
+
prepend-icon="mdi-plus"
|
|
93
|
+
:disabled="uploading"
|
|
94
|
+
@click="pickFiles"
|
|
95
|
+
>
|
|
96
|
+
Add more files
|
|
97
|
+
</v-btn>
|
|
98
|
+
</div>
|
|
99
|
+
</v-col>
|
|
100
|
+
|
|
101
|
+
<v-col v-if="rows.length" cols="12" class="pa-0">
|
|
102
|
+
<v-table class="doc-form__table" density="comfortable">
|
|
103
|
+
<thead>
|
|
104
|
+
<tr>
|
|
105
|
+
<th>Original File Name</th>
|
|
106
|
+
<th>Custom Name</th>
|
|
107
|
+
<th>Remarks</th>
|
|
108
|
+
<th>{{ isAdd ? "Expiry Date (optional)" : "Expiry Date" }}</th>
|
|
109
|
+
<th class="text-center">Action</th>
|
|
110
|
+
</tr>
|
|
111
|
+
</thead>
|
|
112
|
+
<tbody>
|
|
113
|
+
<tr v-for="row in rows" :key="row.id">
|
|
114
|
+
<td>
|
|
115
|
+
<div class="d-flex align-center ga-2">
|
|
116
|
+
<v-icon size="20">mdi-file-document-outline</v-icon>
|
|
117
|
+
<span>{{ row.originalName }}</span>
|
|
118
|
+
</div>
|
|
119
|
+
</td>
|
|
120
|
+
<td>
|
|
121
|
+
<v-text-field
|
|
122
|
+
v-model.trim="row.customName"
|
|
123
|
+
class="filter-field"
|
|
124
|
+
density="compact"
|
|
125
|
+
placeholder="Company Policy"
|
|
126
|
+
hide-details
|
|
127
|
+
/>
|
|
128
|
+
</td>
|
|
129
|
+
<td>
|
|
130
|
+
<v-text-field
|
|
131
|
+
v-model.trim="row.remarks"
|
|
132
|
+
class="filter-field"
|
|
133
|
+
density="compact"
|
|
134
|
+
placeholder="Enter remarks"
|
|
135
|
+
hide-details
|
|
136
|
+
/>
|
|
137
|
+
</td>
|
|
138
|
+
<td>
|
|
139
|
+
<!--
|
|
140
|
+
No `min`. A past date is legitimate on both paths: an estate
|
|
141
|
+
may file a contract that already lapsed, and a renewal that
|
|
142
|
+
does not move the date forward is defined to leave the
|
|
143
|
+
document on the Expired tab rather than be rejected.
|
|
144
|
+
-->
|
|
145
|
+
<InputDatePicker
|
|
146
|
+
v-model="row.expiryDate"
|
|
147
|
+
density="compact"
|
|
148
|
+
clearable
|
|
149
|
+
hide-details
|
|
150
|
+
/>
|
|
151
|
+
</td>
|
|
152
|
+
<td class="text-center">
|
|
153
|
+
<v-btn
|
|
154
|
+
icon="mdi-trash-can-outline"
|
|
155
|
+
variant="text"
|
|
156
|
+
size="small"
|
|
157
|
+
density="comfortable"
|
|
158
|
+
aria-label="Remove file"
|
|
159
|
+
@click="removeRow(row.id)"
|
|
160
|
+
/>
|
|
161
|
+
</td>
|
|
162
|
+
</tr>
|
|
163
|
+
</tbody>
|
|
164
|
+
</v-table>
|
|
165
|
+
</v-col>
|
|
166
|
+
</template>
|
|
167
|
+
|
|
168
|
+
<v-col cols="12" class="pt-0">
|
|
169
|
+
<p v-if="message" class="text-error text-center text-body-2 mb-0">
|
|
170
|
+
{{ message }}
|
|
171
|
+
</p>
|
|
172
|
+
</v-col>
|
|
39
173
|
</v-card-text>
|
|
40
174
|
|
|
41
|
-
<!-- Was a `v-toolbar`: a grey slab with square (`tile`) buttons and a
|
|
42
|
-
hardcoded `color="black"` primary. The gate on Submit is unchanged. -->
|
|
43
175
|
<v-card-actions class="screen-modal__footer">
|
|
44
176
|
<v-row no-gutters>
|
|
45
177
|
<v-col cols="6" class="pr-2">
|
|
@@ -47,8 +179,8 @@
|
|
|
47
179
|
block
|
|
48
180
|
variant="outlined"
|
|
49
181
|
class="text-none screen-btn-ghost"
|
|
182
|
+
:disabled="saving"
|
|
50
183
|
@click="cancel"
|
|
51
|
-
:disabled="disable"
|
|
52
184
|
>
|
|
53
185
|
Cancel
|
|
54
186
|
</v-btn>
|
|
@@ -59,24 +191,38 @@
|
|
|
59
191
|
block
|
|
60
192
|
variant="flat"
|
|
61
193
|
class="text-none screen-btn-primary"
|
|
62
|
-
:disabled="
|
|
63
|
-
|
|
64
|
-
disable ||
|
|
65
|
-
document.attachment.length == 0 ||
|
|
66
|
-
document.name == ''
|
|
67
|
-
"
|
|
194
|
+
:disabled="!canSubmit || saving"
|
|
195
|
+
:loading="saving"
|
|
68
196
|
@click="submit"
|
|
69
|
-
:loading="disable"
|
|
70
197
|
>
|
|
71
|
-
|
|
198
|
+
{{ submitLabel }}
|
|
72
199
|
</v-btn>
|
|
73
200
|
</v-col>
|
|
74
201
|
</v-row>
|
|
75
202
|
</v-card-actions>
|
|
76
203
|
</v-card>
|
|
77
204
|
</template>
|
|
205
|
+
|
|
78
206
|
<script setup lang="ts">
|
|
207
|
+
/** The upload filter. Kept beside `deriveTypeFromFilename`, which must agree. */
|
|
208
|
+
const ACCEPT = ".pdf,.csv,.doc,.docx";
|
|
209
|
+
|
|
210
|
+
type DocumentRow = {
|
|
211
|
+
/** Stored file id, returned by `addFile`. Also the row key. */
|
|
212
|
+
id: string;
|
|
213
|
+
originalName: string;
|
|
214
|
+
customName: string;
|
|
215
|
+
remarks: string;
|
|
216
|
+
/** `YYYY-MM-DD` from `InputDatePicker`, or null for "never expires". */
|
|
217
|
+
expiryDate: string | null;
|
|
218
|
+
};
|
|
219
|
+
|
|
79
220
|
const prop = defineProps({
|
|
221
|
+
/**
|
|
222
|
+
* "add" uploads any number of new documents, "update" replaces the file on
|
|
223
|
+
* one existing document and re-dates it, "edit" changes title and remarks
|
|
224
|
+
* only. They are three dialogs in the design and three payloads here.
|
|
225
|
+
*/
|
|
80
226
|
mode: {
|
|
81
227
|
type: String,
|
|
82
228
|
default: "add",
|
|
@@ -98,26 +244,55 @@ const prop = defineProps({
|
|
|
98
244
|
const emit = defineEmits(["cancel", "success"]);
|
|
99
245
|
|
|
100
246
|
const { add, updateById } = useDocument();
|
|
101
|
-
const { getFileById } = useFile();
|
|
247
|
+
const { addFile, getFileById } = useFile();
|
|
102
248
|
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
249
|
+
const isAdd = computed(() => prop.mode === "add");
|
|
250
|
+
const isEdit = computed(() => prop.mode === "edit");
|
|
251
|
+
const isUpdate = computed(() => prop.mode === "update");
|
|
106
252
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
253
|
+
const heading = computed(() => {
|
|
254
|
+
if (isEdit.value) return "Edit Document";
|
|
255
|
+
if (isUpdate.value) return "Update- Document";
|
|
256
|
+
return "Add Document";
|
|
111
257
|
});
|
|
112
258
|
|
|
259
|
+
const submitLabel = computed(() => (isEdit.value ? "Apply" : "Submit"));
|
|
260
|
+
const dropLabel = computed(() => (isAdd.value ? "Select Files" : "Select File"));
|
|
261
|
+
|
|
262
|
+
const rows = ref<DocumentRow[]>([]);
|
|
263
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
264
|
+
const dragging = ref(false);
|
|
265
|
+
const uploading = ref(false);
|
|
266
|
+
const saving = ref(false);
|
|
267
|
+
const message = ref("");
|
|
268
|
+
|
|
269
|
+
const editTitle = ref("");
|
|
270
|
+
const editRemarks = ref("");
|
|
271
|
+
const existingFileName = ref("");
|
|
272
|
+
|
|
113
273
|
const route = useRoute();
|
|
114
274
|
const siteId = route.params.site as string;
|
|
115
275
|
const orgId = route.params.org as string;
|
|
116
276
|
const routeParentId = computed(() =>
|
|
117
|
-
typeof route.query.parentId === "string" ? route.query.parentId : ""
|
|
277
|
+
typeof route.query.parentId === "string" ? route.query.parentId : "",
|
|
118
278
|
);
|
|
119
279
|
const effectiveParentId = computed(() => prop.parentId || routeParentId.value);
|
|
120
280
|
|
|
281
|
+
const selectionSummary = computed(() =>
|
|
282
|
+
rows.value.length === 1 ? "1 file selected" : `${rows.value.length} files selected`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Every row needs a name, because `name` is what the list draws and what the
|
|
287
|
+
* server requires. Blank-but-present rows are the realistic mistake, so the
|
|
288
|
+
* gate is on trimmed emptiness rather than on the form's validity flag.
|
|
289
|
+
*/
|
|
290
|
+
const canSubmit = computed(() => {
|
|
291
|
+
if (uploading.value) return false;
|
|
292
|
+
if (isEdit.value) return editTitle.value.trim().length > 0;
|
|
293
|
+
return rows.value.length > 0 && rows.value.every((r) => r.customName.trim());
|
|
294
|
+
});
|
|
295
|
+
|
|
121
296
|
function getAttachmentId(attachment: any): string {
|
|
122
297
|
if (!attachment) return "";
|
|
123
298
|
if (Array.isArray(attachment)) {
|
|
@@ -130,102 +305,222 @@ function getAttachmentId(attachment: any): string {
|
|
|
130
305
|
return String(attachment?._id || attachment?.id || "");
|
|
131
306
|
}
|
|
132
307
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
308
|
+
function deriveTypeFromFilename(name = "") {
|
|
309
|
+
const n = String(name).toLowerCase();
|
|
310
|
+
if (n.endsWith(".pdf")) return "pdf";
|
|
311
|
+
if (n.endsWith(".csv")) return "csv";
|
|
312
|
+
if (n.endsWith(".doc") || n.endsWith(".docx")) return "doc";
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** `Contract.doc` -> `Contract`, so the name field starts somewhere sensible. */
|
|
317
|
+
function stripExtension(name: string) {
|
|
318
|
+
const dot = name.lastIndexOf(".");
|
|
319
|
+
return dot > 0 ? name.slice(0, dot) : name;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** `2026-12-13` -> a date-only ISO instant the server's `Joi.date()` accepts. */
|
|
323
|
+
function toIsoOrNull(value: string | null) {
|
|
324
|
+
return value ? new Date(`${value}T00:00:00.000Z`).toISOString() : null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** An ISO instant from the server -> the `YYYY-MM-DD` the picker binds to. */
|
|
328
|
+
function toPickerDate(value: unknown) {
|
|
329
|
+
if (!value) return null;
|
|
330
|
+
const d = new Date(value as string);
|
|
331
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const existingAttachmentId = computed(() =>
|
|
335
|
+
getAttachmentId(prop.document?.attachment),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
onMounted(async () => {
|
|
339
|
+
if (!isEdit.value && !isUpdate.value) return;
|
|
340
|
+
|
|
341
|
+
editTitle.value = prop.document?.name ?? "";
|
|
342
|
+
editRemarks.value = String((prop.document as any)?.remarks ?? "");
|
|
343
|
+
|
|
344
|
+
const id = existingAttachmentId.value;
|
|
345
|
+
if (!id) return;
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const file = await getFileById(id);
|
|
349
|
+
existingFileName.value = file?.data?.name ?? id;
|
|
350
|
+
} catch {
|
|
351
|
+
// The name is decoration on a read-only chip; the id still saves.
|
|
352
|
+
existingFileName.value = id;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
function pickFiles() {
|
|
357
|
+
if (uploading.value) return;
|
|
358
|
+
fileInput.value?.click();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function onDrop(event: DragEvent) {
|
|
362
|
+
dragging.value = false;
|
|
363
|
+
const files = Array.from(event.dataTransfer?.files ?? []);
|
|
364
|
+
if (files.length) void uploadFiles(files);
|
|
140
365
|
}
|
|
141
366
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
367
|
+
function onPick(event: Event) {
|
|
368
|
+
const input = event.target as HTMLInputElement;
|
|
369
|
+
const files = Array.from(input.files ?? []);
|
|
370
|
+
// Cleared so re-picking the same file fires `change` again.
|
|
371
|
+
input.value = "";
|
|
372
|
+
if (files.length) void uploadFiles(files);
|
|
373
|
+
}
|
|
147
374
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
375
|
+
async function uploadFiles(files: File[]) {
|
|
376
|
+
message.value = "";
|
|
377
|
+
uploading.value = true;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// Update replaces one file, so a second pick supersedes the first rather
|
|
381
|
+
// than queueing behind it.
|
|
382
|
+
const queue = isAdd.value ? files : files.slice(0, 1);
|
|
383
|
+
|
|
384
|
+
for (const file of queue) {
|
|
385
|
+
if (!deriveTypeFromFilename(file.name)) {
|
|
386
|
+
message.value = "Unsupported file type. Allowed: pdf, csv, doc, docx.";
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const stored = await addFile(file);
|
|
391
|
+
if (!stored?.id) {
|
|
392
|
+
message.value = "Upload failed. Please try again.";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const row: DocumentRow = {
|
|
397
|
+
id: stored.id,
|
|
398
|
+
originalName: file.name,
|
|
399
|
+
customName: stripExtension(file.name),
|
|
400
|
+
remarks: "",
|
|
401
|
+
expiryDate: null,
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
if (isAdd.value) {
|
|
405
|
+
rows.value = [...rows.value, row];
|
|
406
|
+
} else {
|
|
407
|
+
// Carry the existing document's title and expiry onto the replacement,
|
|
408
|
+
// so a renewal that only swaps the file keeps its identity.
|
|
409
|
+
row.customName = editTitle.value || row.customName;
|
|
410
|
+
row.remarks = editRemarks.value;
|
|
411
|
+
row.expiryDate = toPickerDate((prop.document as any)?.expiryDate);
|
|
412
|
+
rows.value = [row];
|
|
153
413
|
}
|
|
154
414
|
}
|
|
415
|
+
} catch (error: any) {
|
|
416
|
+
message.value = error?.response?._data?.message || "Upload failed.";
|
|
417
|
+
} finally {
|
|
418
|
+
uploading.value = false;
|
|
155
419
|
}
|
|
156
|
-
|
|
420
|
+
}
|
|
157
421
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
if (n.endsWith(".pdf")) return "pdf";
|
|
161
|
-
if (n.endsWith(".csv")) return "csv";
|
|
162
|
-
if (n.endsWith(".doc") || n.endsWith(".docx")) return "doc";
|
|
163
|
-
return "";
|
|
422
|
+
function removeRow(id: string) {
|
|
423
|
+
rows.value = rows.value.filter((row) => row.id !== id);
|
|
164
424
|
}
|
|
165
425
|
|
|
166
426
|
function cancel() {
|
|
167
|
-
// createMore.value = false;
|
|
168
427
|
message.value = "";
|
|
169
428
|
emit("cancel");
|
|
170
429
|
}
|
|
171
430
|
|
|
172
431
|
async function submit() {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (prop.mode === "add") {
|
|
176
|
-
const fileId = Array.isArray(document.value.attachment)
|
|
177
|
-
? document.value.attachment[0]
|
|
178
|
-
: document.value.attachment;
|
|
179
|
-
|
|
180
|
-
const fileData = await getFileById(fileId);
|
|
181
|
-
const fileName = fileData?.data?.name || String(fileId || "");
|
|
182
|
-
const derivedType = deriveTypeFromFilename(fileName);
|
|
183
|
-
|
|
184
|
-
if (!derivedType) {
|
|
185
|
-
throw new Error("Unsupported file type. Allowed: pdf, csv, doc.");
|
|
186
|
-
}
|
|
432
|
+
saving.value = true;
|
|
433
|
+
message.value = "";
|
|
187
434
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
435
|
+
try {
|
|
436
|
+
if (isAdd.value) {
|
|
437
|
+
await add({
|
|
438
|
+
// `name` is required on the envelope even when every item names
|
|
439
|
+
// itself; the first row stands in for it.
|
|
440
|
+
name: rows.value[0].customName,
|
|
193
441
|
status: "active",
|
|
194
442
|
org: orgId,
|
|
195
443
|
site: siteId,
|
|
196
444
|
parentId: effectiveParentId.value,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
445
|
+
items: rows.value.map((row) => ({
|
|
446
|
+
name: row.customName,
|
|
447
|
+
attachment: [row.id],
|
|
448
|
+
type: deriveTypeFromFilename(row.originalName),
|
|
449
|
+
remarks: row.remarks,
|
|
450
|
+
expiryDate: toIsoOrNull(row.expiryDate),
|
|
451
|
+
})),
|
|
452
|
+
});
|
|
200
453
|
}
|
|
201
454
|
|
|
202
|
-
if (
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
if (fileId) {
|
|
213
|
-
const fileData = await getFileById(fileId);
|
|
214
|
-
const fileName = fileData?.data?.name || String(fileId || "");
|
|
215
|
-
const derivedType = deriveTypeFromFilename(fileName);
|
|
216
|
-
if (derivedType) payload.type = derivedType;
|
|
217
|
-
payload.size = fileData?.data?.size ?? fileData?.data?.file_size ?? 0;
|
|
218
|
-
}
|
|
455
|
+
if (isUpdate.value) {
|
|
456
|
+
const row = rows.value[0];
|
|
457
|
+
await updateById(prop.document._id ?? "", {
|
|
458
|
+
name: row.customName,
|
|
459
|
+
attachment: [row.id],
|
|
460
|
+
type: deriveTypeFromFilename(row.originalName),
|
|
461
|
+
remarks: row.remarks,
|
|
462
|
+
expiryDate: toIsoOrNull(row.expiryDate),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
219
465
|
|
|
220
|
-
|
|
466
|
+
if (isEdit.value) {
|
|
467
|
+
// The attachment is resent unchanged on purpose. The update service
|
|
468
|
+
// treats a missing `attachment` as "no files", blanking the document's
|
|
469
|
+
// file and zeroing its size - so a title-only edit would destroy the
|
|
470
|
+
// document it was meant to rename.
|
|
471
|
+
await updateById(prop.document._id ?? "", {
|
|
472
|
+
name: editTitle.value,
|
|
473
|
+
remarks: editRemarks.value,
|
|
474
|
+
attachment: existingAttachmentId.value
|
|
475
|
+
? [existingAttachmentId.value]
|
|
476
|
+
: [],
|
|
477
|
+
});
|
|
221
478
|
}
|
|
222
479
|
|
|
223
480
|
emit("success");
|
|
224
481
|
} catch (error: any) {
|
|
225
482
|
message.value =
|
|
226
|
-
error
|
|
483
|
+
error?.response?._data?.message || "Failed to save document.";
|
|
227
484
|
} finally {
|
|
228
|
-
|
|
485
|
+
saving.value = false;
|
|
229
486
|
}
|
|
230
487
|
}
|
|
231
488
|
</script>
|
|
489
|
+
|
|
490
|
+
<style scoped>
|
|
491
|
+
.doc-form__drop {
|
|
492
|
+
display: flex;
|
|
493
|
+
align-items: center;
|
|
494
|
+
justify-content: center;
|
|
495
|
+
min-height: 120px;
|
|
496
|
+
border: 1px dashed var(--border);
|
|
497
|
+
border-radius: var(--r-inner);
|
|
498
|
+
cursor: pointer;
|
|
499
|
+
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.doc-form__drop:hover,
|
|
503
|
+
.doc-form__drop--over {
|
|
504
|
+
border-color: var(--text);
|
|
505
|
+
background-color: var(--hover);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.doc-form__chip {
|
|
509
|
+
display: flex;
|
|
510
|
+
align-items: center;
|
|
511
|
+
padding: 12px;
|
|
512
|
+
border: 1px dashed var(--border);
|
|
513
|
+
border-radius: var(--r-inner);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.doc-form__table :deep(th) {
|
|
517
|
+
font-size: var(--fs-cell);
|
|
518
|
+
font-weight: 500;
|
|
519
|
+
white-space: nowrap;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.doc-form__table :deep(td) {
|
|
523
|
+
padding-top: 8px;
|
|
524
|
+
padding-bottom: 8px;
|
|
525
|
+
}
|
|
526
|
+
</style>
|