@7365admin1/layer-common 4.35.1-staging.434 → 4.35.1-staging.436

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.
@@ -1,16 +1,12 @@
1
1
  <template>
2
2
  <ImportExcelDialog
3
3
  v-model="show"
4
- :title="kind === 'receipts' ? 'Import Deliveries from Excel' : 'Import Items from Excel'"
5
- :noun="kind === 'receipts' ? 'delivery lines' : 'items'"
6
- name-label="Item"
7
- :fill-hint="
8
- kind === 'receipts'
9
- ? 'One row per item delivered: date received, supplier, PO no., the item (pick from the dropdown), quantity and price. Rows with the same date, supplier and PO no. are saved as one delivery.'
10
- : 'One row per item. Unit, Packing and Category have dropdowns with this store\'s values. Current Stock and the columns after it are the opening stock (date, supplier, PO no.), recorded like a delivery.'
11
- "
12
- :duplicate-note="kind === 'receipts' ? 'Every row is a delivery — uploading the same file twice records it twice.' : 'Items already in the store are skipped and never changed, so uploading twice never doubles stock.'"
13
- :template-file-name="kind === 'receipts' ? `receive-stock-${slug}.xlsx` : `inventory-items-${slug}.xlsx`"
4
+ :title="copy.title"
5
+ :noun="copy.noun"
6
+ :name-label="kind === 'suppliers' ? 'Supplier' : 'Item'"
7
+ :fill-hint="copy.fill"
8
+ :duplicate-note="copy.duplicate"
9
+ :template-file-name="`${copy.file}-${slug}.xlsx`"
14
10
  :download-template="downloadTemplate"
15
11
  :check="(file: File) => run(file, false)"
16
12
  :commit="(file: File) => run(file, true)"
@@ -26,21 +22,54 @@ import type { TImportResult } from "../utils/import-result";
26
22
  const show = defineModel<boolean>({ default: false });
27
23
  const props = defineProps({
28
24
  store: { type: Object as PropType<TInventoryStore>, required: true },
29
- /** items: the item list with opening stock. receipts: bulk deliveries of existing items. */
30
- kind: { type: String as PropType<"items" | "receipts">, default: "items" },
25
+ /** items: the item list with opening stock. receipts: bulk deliveries of existing items. suppliers: the supplier list. */
26
+ kind: { type: String as PropType<"items" | "receipts" | "suppliers">, default: "items" },
31
27
  });
32
28
  const emit = defineEmits(["done"]);
33
29
 
34
- const { importItems, importTemplate, receiveTemplate, importReceipts } = useInventory();
30
+ const { importItems, importTemplate, receiveTemplate, importReceipts, supplierTemplate, importSuppliers } = useInventory();
35
31
  const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
36
32
  const slug = computed(() => String(props.store.name ?? "store").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "store");
37
33
 
38
- const downloadTemplate = () => (props.kind === "receipts" ? receiveTemplate(props.store._id) : importTemplate(props.store._id));
34
+ const COPY = {
35
+ items: {
36
+ title: "Import Items from Excel",
37
+ noun: "items",
38
+ fill: "One row per item. Unit, Packing, Category and Supplier have dropdowns with this store's values (a new supplier can still be typed in). Current Stock and the columns after it are the opening stock (date, supplier, PO no.), recorded like a delivery.",
39
+ duplicate: "Items already in the store are skipped and never changed, so uploading twice never doubles stock.",
40
+ file: "inventory-items",
41
+ done: (n: number) => `${n} item(s) added.`,
42
+ },
43
+ receipts: {
44
+ title: "Import Deliveries from Excel",
45
+ noun: "delivery lines",
46
+ fill: "One row per item delivered: date received, supplier (pick from the dropdown, or type a new one), PO no., the item (pick from the dropdown), quantity and price. Rows with the same date, supplier and PO no. are saved as one delivery.",
47
+ duplicate: "Every row is a delivery — uploading the same file twice records it twice.",
48
+ file: "receive-stock",
49
+ done: (n: number) => `${n} delivery line(s) received.`,
50
+ },
51
+ suppliers: {
52
+ title: "Import Suppliers from Excel",
53
+ noun: "suppliers",
54
+ fill: "One row per supplier. Only Supplier Name is required; contact person, phone, email, address, payment terms and notes are optional.",
55
+ duplicate: "Suppliers already in your list are skipped and left unchanged.",
56
+ file: "suppliers",
57
+ done: (n: number) => `${n} supplier(s) added.`,
58
+ },
59
+ };
60
+ const copy = computed(() => COPY[props.kind]);
61
+
62
+ const downloadTemplate = () =>
63
+ props.kind === "receipts" ? receiveTemplate(props.store._id) : props.kind === "suppliers" ? supplierTemplate(props.store._id) : importTemplate(props.store._id);
39
64
  const run = (file: File, commit: boolean) =>
40
- (props.kind === "receipts" ? importReceipts(props.store._id, file, commit) : importItems(props.store._id, file, commit)) as Promise<TImportResult>;
65
+ (props.kind === "receipts"
66
+ ? importReceipts(props.store._id, file, commit)
67
+ : props.kind === "suppliers"
68
+ ? importSuppliers(props.store._id, file, commit)
69
+ : importItems(props.store._id, file, commit)) as Promise<TImportResult>;
41
70
 
42
71
  function finished(result: TImportResult) {
43
- notify(props.kind === "receipts" ? `${result.ready} delivery line(s) received.` : `${result.ready} item(s) added.`);
72
+ notify(copy.value.done(result.ready));
44
73
  emit("done");
45
74
  }
46
75
  </script>
@@ -58,7 +58,7 @@
58
58
  </v-col>
59
59
  <v-col cols="12" sm="6">
60
60
  <InputLabel title="Supplier" class="mb-1" />
61
- <v-text-field v-model="f.openingSupplier" density="comfortable" maxlength="200" :disabled="!f.openingQty" hint="Optional" persistent-hint />
61
+ <InventorySupplierField v-if="show" v-model="f.openingSupplier" :store-id="store._id" :disabled="!f.openingQty" hint="Optional. Pick from your supplier list, or type a new one." />
62
62
  </v-col>
63
63
  <v-col cols="12" sm="6">
64
64
  <InputLabel title="PO no." class="mb-1" />
@@ -83,7 +83,7 @@
83
83
  <v-row v-if="mode === 'receive'" dense>
84
84
  <v-col cols="12" md="6">
85
85
  <InputLabel title="Supplier" class="mb-1" />
86
- <v-text-field v-model="supplier" density="comfortable" placeholder="e.g. GSK Hardware Pte Ltd" />
86
+ <InventorySupplierField v-if="show" v-model="supplier" :store-id="store._id" />
87
87
  </v-col>
88
88
  <v-col cols="12" md="3">
89
89
  <InputLabel title="PO number" class="mb-1" />
@@ -100,6 +100,9 @@
100
100
  <v-window-item v-if="isStaff" value="reports">
101
101
  <InventoryReportsTab :store="store" :sites="storeSites" />
102
102
  </v-window-item>
103
+ <v-window-item v-if="isStaff" value="suppliers">
104
+ <InventorySuppliersTab :store="store" />
105
+ </v-window-item>
103
106
  <v-window-item v-if="store.access.canManage" value="settings">
104
107
  <InventorySettingsTab :store="store" :org-sites="orgSites" @saved="loadStores" />
105
108
  </v-window-item>
@@ -182,6 +185,7 @@ const tabs = computed(() => {
182
185
  { value: "requests", label: a.canApprove ? "Requests" : "My Requests", badge: a.canApprove ? pendingCount.value : 0 },
183
186
  ...(a.canApprove ? [{ value: "technicians", label: worker.many }] : []),
184
187
  ...(isStaff.value ? [{ value: "reports", label: "Reports" }] : []),
188
+ ...(isStaff.value ? [{ value: "suppliers", label: "Suppliers" }] : []),
185
189
  ...(a.canManage ? [{ value: "settings", label: "Settings" }] : []),
186
190
  ];
187
191
  });
@@ -0,0 +1,93 @@
1
+ <template>
2
+ <div>
3
+ <v-combobox
4
+ v-model="model"
5
+ v-model:search="search"
6
+ :items="names"
7
+ :loading="loading"
8
+ :disabled="disabled"
9
+ :placeholder="placeholder"
10
+ density="comfortable"
11
+ clearable
12
+ hide-details
13
+ maxlength="200"
14
+ aria-label="Supplier — pick from your list or type a new one"
15
+ @blur="snap"
16
+ >
17
+ <template #no-data>
18
+ <v-list-item v-if="typed" prepend-icon="mdi-plus" @click="model = typed">
19
+ <v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
20
+ </v-list-item>
21
+ <v-list-item v-else>
22
+ <v-list-item-title class="text-medium-emphasis">No suppliers yet — type a name to add one</v-list-item-title>
23
+ </v-list-item>
24
+ </template>
25
+ <template v-if="typed && !listedSupplierName(typed, names)" #append-item>
26
+ <v-divider />
27
+ <v-list-item prepend-icon="mdi-plus" @click="model = typed">
28
+ <v-list-item-title>Add "{{ typed }}" as a new supplier</v-list-item-title>
29
+ </v-list-item>
30
+ </template>
31
+ </v-combobox>
32
+ <div class="text-caption mt-1" style="min-height: 20px">
33
+ <span v-if="near" class="text-warning">
34
+ Did you mean <a href="#" class="text-warning font-weight-bold" @click.prevent="model = near">{{ near }}</a>? Otherwise "{{ current }}" is added as a new supplier.
35
+ </span>
36
+ <span v-else-if="isNew" class="text-medium-emphasis">New supplier — it is added to your supplier list when you save.</span>
37
+ <span v-else class="text-medium-emphasis">{{ hint }}</span>
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <script setup lang="ts">
43
+ import useInventory from "../composables/useInventory";
44
+ import { listedSupplierName, nearSupplierName } from "../utils/inventory-suppliers";
45
+
46
+ /**
47
+ * Supplier picker: the organisation's supplier list as a searchable dropdown.
48
+ * A name not in the list can still be typed; the server adds it to the list
49
+ * (name only) when the delivery is saved, and matches any spelling of a listed name.
50
+ */
51
+ const model = defineModel<string>({ default: "" });
52
+ const props = defineProps({
53
+ storeId: { type: String, required: true },
54
+ disabled: { type: Boolean, default: false },
55
+ placeholder: { type: String, default: "Pick a supplier or type a new one" },
56
+ hint: { type: String, default: "Pick from your supplier list, or type a new one." },
57
+ });
58
+
59
+ const { getSuppliers } = useInventory();
60
+ const names = ref<string[]>([]);
61
+ const loading = ref(false);
62
+ const search = ref<string | null>("");
63
+
64
+ const typed = computed(() => String(search.value ?? "").replace(/\s+/g, " ").trim());
65
+ const current = computed(() => String(model.value ?? "").replace(/\s+/g, " ").trim());
66
+ const isNew = computed(() => !!current.value && !listedSupplierName(current.value, names.value));
67
+ const near = computed(() => (isNew.value ? nearSupplierName(current.value, names.value) : undefined));
68
+
69
+ /** A listed supplier typed in another spelling becomes the list's spelling. */
70
+ function snap() {
71
+ if (model.value == null) return void (model.value = ""); // cleared: no supplier, never null to the server
72
+ const listed = listedSupplierName(model.value, names.value);
73
+ if (listed && listed !== model.value) model.value = listed;
74
+ }
75
+ watch(model, snap);
76
+
77
+ async function load() {
78
+ if (!props.storeId) return;
79
+ loading.value = true;
80
+ try {
81
+ const res = await getSuppliers(props.storeId);
82
+ names.value = [...res.items.map((s) => s.name), ...(res.fromDeliveries ?? []).map((s) => s.name)].sort((a, b) =>
83
+ a.localeCompare(b, "en", { numeric: true, sensitivity: "base" }),
84
+ );
85
+ } catch {
86
+ names.value = []; // typing a name still works
87
+ } finally {
88
+ loading.value = false;
89
+ }
90
+ }
91
+ watch(() => props.storeId, load, { immediate: true });
92
+ defineExpose({ reload: load });
93
+ </script>
@@ -0,0 +1,210 @@
1
+ <template>
2
+ <div>
3
+ <AppCard class="pa-4 mb-4">
4
+ <p class="text-body-2 text-medium-emphasis mb-3">
5
+ Your organisation's suppliers, shared by all its stores. They appear in the Supplier dropdown on Receive Stock and on opening stock.
6
+ Only the name is needed — contact details are optional. A supplier typed on a delivery is added here automatically.
7
+ </p>
8
+ <div class="d-flex flex-wrap align-center ga-2">
9
+ <v-text-field
10
+ v-model="search"
11
+ density="compact"
12
+ prepend-inner-icon="mdi-magnify"
13
+ placeholder="Search name, contact, phone or email"
14
+ hide-details
15
+ clearable
16
+ style="min-width: 240px; max-width: 360px"
17
+ />
18
+ <v-switch v-if="canManage" v-model="includeInactive" color="primary" density="compact" hide-details label="Show inactive" class="ml-2" />
19
+ <v-spacer />
20
+ <template v-if="canManage">
21
+ <v-btn variant="outlined" class="text-none" prepend-icon="mdi-file-excel-outline" @click="importOpen = true">Import from Excel</v-btn>
22
+ <v-btn variant="flat" class="text-none screen-btn-primary" prepend-icon="mdi-plus" @click="edit()">Add Supplier</v-btn>
23
+ </template>
24
+ </div>
25
+ </AppCard>
26
+
27
+ <AppCard v-if="canManage && fromDeliveries.length" class="pa-4 mb-4">
28
+ <div class="text-subtitle-2 mb-1">Suppliers on past deliveries, not in the list yet</div>
29
+ <p class="text-caption text-medium-emphasis mb-2">Add them so they can be picked, and fill in their details if you want to.</p>
30
+ <div class="d-flex flex-wrap ga-2 align-center">
31
+ <v-chip v-for="s in fromDeliveries" :key="s.name" variant="outlined" append-icon="mdi-plus" :disabled="adding" @click="addName(s.name)">
32
+ {{ s.name }}
33
+ </v-chip>
34
+ <v-btn size="small" variant="text" class="text-none" :loading="adding" @click="addAll">Add all {{ fromDeliveries.length }}</v-btn>
35
+ </div>
36
+ </AppCard>
37
+
38
+ <AppCard class="pa-0">
39
+ <v-data-table :headers="headers" :items="items" :loading="loading" density="comfortable" :items-per-page="25" no-data-text="No suppliers yet. Add one, import a list, or type a new supplier on Receive Stock.">
40
+ <template #item.name="{ item }">
41
+ <span class="font-weight-medium">{{ item.name }}</span>
42
+ <v-chip v-if="item.status === 'inactive'" size="x-small" class="ml-2">Inactive</v-chip>
43
+ </template>
44
+ <template #item.contact="{ item }">
45
+ <div>{{ item.contactPerson || "—" }}</div>
46
+ <div class="text-caption text-medium-emphasis">{{ [item.phone, item.email].filter(Boolean).join(" · ") }}</div>
47
+ </template>
48
+ <template #item.actions="{ item }">
49
+ <v-btn v-if="canManage" size="small" variant="text" class="text-none" @click="edit(item)">Edit</v-btn>
50
+ </template>
51
+ </v-data-table>
52
+ </AppCard>
53
+
54
+ <v-dialog v-model="dialog" max-width="560">
55
+ <AppCard class="pa-6">
56
+ <div class="text-h6 mb-1">{{ editing ? "Edit supplier" : "Add supplier" }}</div>
57
+ <p class="text-body-2 text-medium-emphasis mb-4">Only the name is required. Fill in the rest only if you want to — you can come back to it later.</p>
58
+ <v-form ref="form" @submit.prevent="save">
59
+ <InputLabel title="Supplier name" required class="mb-1" />
60
+ <v-text-field v-model="f.name" density="comfortable" maxlength="200" :rules="[requiredRule]" autofocus />
61
+ <v-row dense>
62
+ <v-col cols="12" sm="6">
63
+ <InputLabel title="Contact person" class="mb-1" />
64
+ <v-text-field v-model="f.contactPerson" density="comfortable" maxlength="120" hint="Optional" persistent-hint />
65
+ </v-col>
66
+ <v-col cols="12" sm="6">
67
+ <InputLabel title="Phone" class="mb-1" />
68
+ <v-text-field v-model="f.phone" density="comfortable" maxlength="40" hint="Optional" persistent-hint />
69
+ </v-col>
70
+ <v-col cols="12" sm="6">
71
+ <InputLabel title="Email" class="mb-1" />
72
+ <v-text-field v-model="f.email" type="email" density="comfortable" maxlength="200" :rules="[emailRule]" hint="Optional" persistent-hint />
73
+ </v-col>
74
+ <v-col cols="12" sm="6">
75
+ <InputLabel title="Payment terms" class="mb-1" />
76
+ <v-text-field v-model="f.paymentTerms" density="comfortable" maxlength="120" placeholder="e.g. 30 days, COD" hint="Optional" persistent-hint />
77
+ </v-col>
78
+ </v-row>
79
+ <InputLabel title="Address" class="mb-1 mt-2" />
80
+ <v-textarea v-model="f.address" density="comfortable" rows="2" auto-grow maxlength="500" hint="Optional" persistent-hint />
81
+ <InputLabel title="Notes" class="mb-1 mt-2" />
82
+ <v-textarea v-model="f.notes" density="comfortable" rows="2" auto-grow maxlength="1000" hint="Optional" persistent-hint />
83
+ <v-switch
84
+ v-if="editing"
85
+ v-model="active"
86
+ color="primary"
87
+ hide-details
88
+ class="mt-2"
89
+ :label="active ? 'Active — shown in the Supplier dropdown' : 'Inactive — hidden from the dropdown, kept on past deliveries'"
90
+ />
91
+ <div class="d-flex justify-end ga-2 mt-4">
92
+ <v-btn variant="text" class="text-none" @click="dialog = false">Cancel</v-btn>
93
+ <v-btn type="submit" variant="flat" class="text-none screen-btn-primary" :loading="saving">{{ editing ? "Save" : "Add supplier" }}</v-btn>
94
+ </div>
95
+ </v-form>
96
+ </AppCard>
97
+ </v-dialog>
98
+
99
+ <InventoryImportDialog v-model="importOpen" :store="store" kind="suppliers" @done="load" />
100
+ </div>
101
+ </template>
102
+
103
+ <script setup lang="ts">
104
+ import useInventory from "../composables/useInventory";
105
+ import useUtils from "../composables/useUtils";
106
+
107
+ const props = defineProps({
108
+ store: { type: Object as PropType<TInventoryStore>, required: true },
109
+ });
110
+
111
+ const { getSuppliers, createSupplier, updateSupplier } = useInventory();
112
+ const { requiredRule, debounce } = useUtils();
113
+ const notify = inject<(text: string, color?: string) => void>("inventoryNotify", () => {});
114
+
115
+ const items = ref<TInventorySupplier[]>([]);
116
+ const fromDeliveries = ref<{ name: string }[]>([]);
117
+ const canManage = ref(false);
118
+ const loading = ref(false);
119
+ const search = ref<string | null>("");
120
+ const includeInactive = ref(false);
121
+ const importOpen = ref(false);
122
+ const adding = ref(false);
123
+
124
+ const headers = [
125
+ { title: "Supplier", key: "name" },
126
+ { title: "Contact", key: "contact", sortable: false },
127
+ { title: "Payment terms", key: "paymentTerms" },
128
+ { title: "", key: "actions", sortable: false, align: "end" as const },
129
+ ];
130
+
131
+ async function load() {
132
+ loading.value = true;
133
+ try {
134
+ const res = await getSuppliers(props.store._id, { search: search.value || "", includeInactive: includeInactive.value });
135
+ items.value = res.items;
136
+ fromDeliveries.value = res.fromDeliveries ?? [];
137
+ canManage.value = res.canManage;
138
+ } catch (error: any) {
139
+ notify(error?.data?.message || "Could not load the suppliers.", "error");
140
+ } finally {
141
+ loading.value = false;
142
+ }
143
+ }
144
+ const reload = debounce(load, 300);
145
+ watch(search, reload);
146
+ watch(includeInactive, load);
147
+ watch(() => props.store._id, load, { immediate: true });
148
+
149
+ async function addName(name: string) {
150
+ adding.value = true;
151
+ try {
152
+ await createSupplier(props.store._id, { name });
153
+ notify(`"${name}" added to your suppliers.`);
154
+ await load();
155
+ } catch (error: any) {
156
+ notify(error?.data?.message || "Could not add the supplier.", "error");
157
+ } finally {
158
+ adding.value = false;
159
+ }
160
+ }
161
+ async function addAll() {
162
+ adding.value = true;
163
+ let added = 0;
164
+ for (const s of fromDeliveries.value) {
165
+ try {
166
+ await createSupplier(props.store._id, { name: s.name });
167
+ added++;
168
+ } catch {
169
+ /* already added by someone else: skip */
170
+ }
171
+ }
172
+ adding.value = false;
173
+ notify(`${added} supplier(s) added.`);
174
+ await load();
175
+ }
176
+
177
+ // ---- add / edit
178
+ const blank = () => ({ name: "", contactPerson: "", phone: "", email: "", address: "", paymentTerms: "", notes: "" });
179
+ const dialog = ref(false);
180
+ const form = ref();
181
+ const saving = ref(false);
182
+ const editing = ref<TInventorySupplier | null>(null);
183
+ const f = reactive(blank());
184
+ const active = ref(true);
185
+ const emailRule = (v: string) => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || "Enter a valid email, or leave it blank";
186
+
187
+ function edit(s?: TInventorySupplier) {
188
+ editing.value = s ?? null;
189
+ Object.assign(f, blank(), s ? { name: s.name, contactPerson: s.contactPerson, phone: s.phone, email: s.email, address: s.address, paymentTerms: s.paymentTerms, notes: s.notes } : {});
190
+ active.value = s ? s.status === "active" : true;
191
+ dialog.value = true;
192
+ }
193
+
194
+ async function save() {
195
+ const { valid } = await form.value.validate();
196
+ if (!valid) return;
197
+ saving.value = true;
198
+ try {
199
+ if (editing.value) await updateSupplier(props.store._id, editing.value._id, { ...f, status: active.value ? "active" : "inactive" });
200
+ else await createSupplier(props.store._id, { ...f });
201
+ notify(editing.value ? "Supplier saved." : `"${f.name.trim()}" added to your suppliers.`);
202
+ dialog.value = false;
203
+ await load();
204
+ } catch (error: any) {
205
+ notify(error?.data?.message || "Could not save the supplier.", "error");
206
+ } finally {
207
+ saving.value = false;
208
+ }
209
+ }
210
+ </script>
@@ -168,6 +168,14 @@
168
168
  Assign Role
169
169
  </v-list-item>
170
170
 
171
+ <v-list-item
172
+ v-if="siteAccessAllowed && item.user"
173
+ data-test="member-site-access"
174
+ @click="openSiteAccess(item)"
175
+ >
176
+ Site access
177
+ </v-list-item>
178
+
171
179
  <v-list-item
172
180
  v-if="currentStatus === 'active' && canSuspendMembers"
173
181
  @click="openUpdateDialog(item._id, 'suspended')"
@@ -241,6 +249,17 @@
241
249
  INSIDE the `v-form` because Submit is a `type="submit"` and moving it out
242
250
  would break the form's own submit.
243
251
  -->
252
+ <v-dialog v-model="siteAccessDialog" max-width="640">
253
+ <MemberSiteAccess
254
+ v-if="siteAccessDialog && siteAccessUser"
255
+ :org="props.orgId"
256
+ :user="siteAccessUser"
257
+ :name="siteAccessName"
258
+ @cancel="siteAccessDialog = false"
259
+ @saved="onSiteAccessSaved"
260
+ />
261
+ </v-dialog>
262
+
244
263
  <v-dialog v-model="assignRoleDialog" max-width="520">
245
264
  <v-card class="screen-modal">
246
265
  <v-card-title>Assign Role</v-card-title>
@@ -578,6 +597,24 @@ function setMember({
578
597
  if (dialog) refreshRoles();
579
598
  }
580
599
 
600
+ /* Site access (owner request 2026-09-28): on the organisation-level Members
601
+ screen, whoever may assign roles can choose a member's sites. The server
602
+ decides again (owner / org-wide admin only). */
603
+ const siteAccessAllowed = computed(() => props.assignRole && !props.siteId);
604
+ const siteAccessDialog = ref(false);
605
+ const siteAccessUser = ref("");
606
+ const siteAccessName = ref("");
607
+ function openSiteAccess(item: Record<string, any>) {
608
+ siteAccessUser.value = String(item.user ?? "");
609
+ siteAccessName.value = String(item.name || item.email || "");
610
+ siteAccessDialog.value = true;
611
+ }
612
+ function onSiteAccessSaved(msg: string) {
613
+ siteAccessDialog.value = false;
614
+ showMessage(msg, "success");
615
+ getAll();
616
+ }
617
+
581
618
  const roles = ref<Array<Record<string, any>>>([]);
582
619
  // Web audit M1: the member's own role is always offered, even when the
583
620
  // site-scoped list below does not return it (widen-only, `utils/role.ts`).
@@ -0,0 +1,135 @@
1
+ <template>
2
+ <v-card class="screen-modal" data-test="site-access">
3
+ <v-card-title>Site access{{ props.name ? ` — ${props.name}` : "" }}</v-card-title>
4
+ <v-card-text class="screen-modal__body">
5
+ <p class="text-body-2 mb-3">
6
+ Choose the sites this person can open and their role at each. They will see only these sites.
7
+ </p>
8
+
9
+ <div v-if="loading" class="py-4 text-center"><v-progress-circular indeterminate /></div>
10
+ <p v-else-if="loadError" class="text-error">{{ loadError }}</p>
11
+ <template v-else>
12
+ <div v-if="orgWide.length" class="mb-4" data-test="site-access-org-wide">
13
+ <p class="text-body-2 mb-1">
14
+ <strong>Organisation access:</strong>
15
+ {{ orgWide.map((m) => m.roleName || "role").join(", ") }} — this reaches <strong>every</strong> site.
16
+ </p>
17
+ <v-checkbox
18
+ v-model="removeOrgAccess"
19
+ density="compact"
20
+ hide-details
21
+ label="Remove organisation access and limit this person to the sites below"
22
+ />
23
+ <p v-if="removeOrgAccess" class="text-caption">
24
+ They will no longer appear on the Organization app's Members list. You can change their sites again here from any site's member, or invite them again.
25
+ </p>
26
+ </div>
27
+
28
+ <p v-if="!choices.length" class="text-body-2">This organisation has no sites yet.</p>
29
+
30
+ <div v-else>
31
+ <div class="d-flex align-center ga-2 mb-2">
32
+ <v-select
33
+ v-model="sameRole"
34
+ :items="sharedNames"
35
+ label="Same role for all selected sites"
36
+ density="compact"
37
+ hide-details
38
+ clearable
39
+ class="filter-field"
40
+ data-test="site-access-same-role"
41
+ />
42
+ <v-btn variant="outlined" :disabled="!sameRole" @click="applySame()">Apply</v-btn>
43
+ </div>
44
+
45
+ <div v-for="(c, i) in choices" :key="c.site" class="d-flex align-center ga-2 py-1" data-test="site-access-row">
46
+ <v-checkbox v-model="choices[i].on" density="compact" hide-details :label="c.name" style="min-width: 220px" />
47
+ <v-select
48
+ v-if="c.roles.length"
49
+ v-model="choices[i].role"
50
+ :items="c.roles"
51
+ item-title="name"
52
+ item-value="_id"
53
+ :disabled="!c.on"
54
+ label="Role"
55
+ density="compact"
56
+ hide-details
57
+ class="filter-field"
58
+ />
59
+ <span v-else class="text-caption">No roles created for {{ c.name }} yet — create one in that site's Roles &amp; Permissions.</span>
60
+ </div>
61
+ <p v-for="c in choices.filter((x) => x.on && !x.role && x.replacedRole)" :key="`r-${c.site}`" class="text-caption">
62
+ {{ c.name }}: "{{ c.replacedRole }}" is not one of this site's own roles — choose one of {{ c.name }}'s roles.
63
+ </p>
64
+ </div>
65
+
66
+ <p v-if="missing.length" class="text-error text-caption mt-2">Choose a role for: {{ missing.join(", ") }}</p>
67
+ <p v-if="saveError" class="text-error mt-2">{{ saveError }}</p>
68
+ </template>
69
+ </v-card-text>
70
+ <div class="screen-modal__footer">
71
+ <v-btn class="screen-btn-ghost" variant="outlined" @click="emit('cancel')">Cancel</v-btn>
72
+ <v-btn class="screen-btn-primary" variant="flat" :loading="saving" :disabled="loading || !!loadError || missing.length > 0" @click="save()">
73
+ Save
74
+ </v-btn>
75
+ </div>
76
+ </v-card>
77
+ </template>
78
+
79
+ <script setup lang="ts">
80
+ // Relative import: this layer's `utils/` is not auto-imported into consumers.
81
+ import { applySameRole, initialChoices, sharedRoleNames, toPayload, type SiteChoice } from "../utils/site-access";
82
+
83
+ const props = defineProps({
84
+ org: { type: String, required: true },
85
+ user: { type: String, required: true },
86
+ name: { type: String, default: "" },
87
+ });
88
+ const emit = defineEmits<{ (e: "cancel"): void; (e: "saved", message: string): void }>();
89
+
90
+ const { getSiteAccess, saveSiteAccess } = useMember();
91
+
92
+ const loading = ref(true);
93
+ const loadError = ref("");
94
+ const saving = ref(false);
95
+ const saveError = ref("");
96
+ const choices = ref<SiteChoice[]>([]);
97
+ const orgWide = ref<Array<{ roleName?: string }>>([]);
98
+ const removeOrgAccess = ref(false);
99
+ const sameRole = ref<string | null>(null);
100
+
101
+ const sharedNames = computed(() => sharedRoleNames(choices.value));
102
+ const missing = computed(() => toPayload(choices.value).missing);
103
+
104
+ function applySame() {
105
+ if (sameRole.value) choices.value = applySameRole(choices.value, sameRole.value);
106
+ }
107
+
108
+ async function load() {
109
+ loading.value = true;
110
+ loadError.value = "";
111
+ try {
112
+ const res = await getSiteAccess(props.org, props.user);
113
+ choices.value = initialChoices(res?.sites ?? []);
114
+ orgWide.value = res?.orgWide ?? [];
115
+ } catch (e: any) {
116
+ loadError.value = e?.data?.message || "Site access could not be loaded. Only the organisation's owner or an admin can change it.";
117
+ }
118
+ loading.value = false;
119
+ }
120
+
121
+ async function save() {
122
+ saving.value = true;
123
+ saveError.value = "";
124
+ try {
125
+ const { sites } = toPayload(choices.value);
126
+ await saveSiteAccess({ org: props.org, user: props.user, sites, removeOrgAccess: removeOrgAccess.value });
127
+ emit("saved", sites.length ? `Site access saved: ${sites.length} site(s).` : "Site access saved: no sites.");
128
+ } catch (e: any) {
129
+ saveError.value = e?.data?.message || "Site access could not be saved.";
130
+ }
131
+ saving.value = false;
132
+ }
133
+
134
+ load();
135
+ </script>
@@ -83,6 +83,20 @@ export default function useInventory() {
83
83
  body.append("file", file);
84
84
  return api<TImportResult>(`/stores/${store}/receive-import`, { method: "POST", body, query: { commit } });
85
85
  },
86
+
87
+ /** The organisation's supplier list (active), plus — for managers — past-delivery names not yet in it. */
88
+ getSuppliers: (store: string, query: { search?: string; includeInactive?: boolean } = {}) =>
89
+ api<{ items: TInventorySupplier[]; fromDeliveries: { name: string; lastUsed: string; count: number }[]; canManage: boolean }>(`/stores/${store}/suppliers`, { query }),
90
+ createSupplier: (store: string, body: Partial<TInventorySupplier> & { name: string }) =>
91
+ api<TInventorySupplier>(`/stores/${store}/suppliers`, { method: "POST", body }),
92
+ updateSupplier: (store: string, id: string, body: Partial<TInventorySupplier>) =>
93
+ api<TInventorySupplier>(`/stores/${store}/suppliers/${id}`, { method: "PATCH", body }),
94
+ supplierTemplate: (store: string) => api<Blob>(`/stores/${store}/suppliers-template`, { responseType: "blob" }),
95
+ importSuppliers: (store: string, file: File, commit: boolean) => {
96
+ const body = new FormData();
97
+ body.append("file", file);
98
+ return api<TImportResult>(`/stores/${store}/suppliers-import`, { method: "POST", body, query: { commit } });
99
+ },
86
100
  };
87
101
  }
88
102
 
@@ -173,7 +173,30 @@ export default function useMember() {
173
173
  );
174
174
  }
175
175
 
176
+ /** One member's sites and the role at each (org owner / admin only). */
177
+ function getSiteAccess(org: string, user: string) {
178
+ return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
179
+ method: "GET",
180
+ query: { org, user },
181
+ });
182
+ }
183
+
184
+ /** Replace one member's sites: [{ site, role }]; optionally drop org-wide access. */
185
+ function saveSiteAccess(body: {
186
+ org: string;
187
+ user: string;
188
+ sites: Array<{ site: string; role: string }>;
189
+ removeOrgAccess?: boolean;
190
+ }) {
191
+ return useNuxtApp().$api<Record<string, any>>("/api/members/site-access", {
192
+ method: "PUT",
193
+ body,
194
+ });
195
+ }
196
+
176
197
  return {
198
+ getSiteAccess,
199
+ saveSiteAccess,
177
200
  members,
178
201
  member,
179
202
  page,
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.35.1-staging.434",
5
+ "version": "4.35.1-staging.436",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -97,3 +97,16 @@ declare type TInventoryTransaction = {
97
97
  };
98
98
 
99
99
  declare type TInventoryPeriod = { from: string; to: string; site?: string; locationType?: string };
100
+
101
+ /** An organisation's supplier (shared by its stores). Only the name is required. */
102
+ declare type TInventorySupplier = {
103
+ _id: string;
104
+ name: string;
105
+ contactPerson: string;
106
+ phone: string;
107
+ email: string;
108
+ address: string;
109
+ paymentTerms: string;
110
+ notes: string;
111
+ status: "active" | "inactive";
112
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Supplier-name helpers for the inventory supplier picker. Browser-safe, no
3
+ * Node globals; names are unique so Nuxt auto-imports cannot clash.
4
+ * The server applies the same rules (API-core `utils/inventory-suppliers.ts`).
5
+ */
6
+
7
+ /** Trimmed, spaces collapsed, lower case: two names with the same key are the same supplier. */
8
+ export function supplierNameKey(name: unknown): string {
9
+ return String(name ?? "").replace(/\s+/g, " ").trim().toLowerCase();
10
+ }
11
+
12
+ /** The list's own spelling of `name`, when the list already has it in any spelling. */
13
+ export function listedSupplierName(name: unknown, names: string[]): string | undefined {
14
+ const key = supplierNameKey(name);
15
+ return key ? names.find((n) => supplierNameKey(n) === key) : undefined;
16
+ }
17
+
18
+ const COMPANY_WORDS = /\b(pte|ltd|private|limited|co|company|llp|inc|corp|sdn|bhd|the)\b/g;
19
+ const coreName = (name: string) => supplierNameKey(name).replace(/[^a-z0-9 ]+/g, " ").replace(COMPANY_WORDS, " ").replace(/\s+/g, " ").trim();
20
+
21
+ function editDistanceWithin(a: string, b: string, cap: number) {
22
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
23
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
24
+ for (let i = 1; i <= a.length; i++) {
25
+ const cur = [i];
26
+ for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
27
+ prev = cur;
28
+ }
29
+ return prev[b.length];
30
+ }
31
+
32
+ /** A listed supplier a NEW name probably means ("ABC Trading" → "ABC Trading Pte Ltd"); undefined when listed or nothing close. */
33
+ export function nearSupplierName(name: string, names: string[]): string | undefined {
34
+ if (!supplierNameKey(name) || listedSupplierName(name, names)) return undefined;
35
+ const c = coreName(name);
36
+ if (c.length < 3) return undefined;
37
+ const cap = c.length >= 8 ? 2 : 1;
38
+ return names.find((n) => {
39
+ const o = coreName(n);
40
+ if (!o) return false;
41
+ if (o === c) return true;
42
+ if (o.length >= 3 && (o.startsWith(`${c} `) || c.startsWith(`${o} `))) return true;
43
+ return o.length >= 4 && editDistanceWithin(c, o, cap) <= cap;
44
+ });
45
+ }
package/utils/role.ts CHANGED
@@ -76,12 +76,13 @@ export function otherSiteBannerText(count: unknown): string | null {
76
76
 
77
77
  /**
78
78
  * Can this member's CURRENT role be used on the screen's site? The server's
79
- * `roleState` is empty for a usable role and names the problem otherwise
79
+ * `roleState` is "ok" (or absent, older servers) for a usable role and names the problem otherwise
80
80
  * ("other-site", "other-org", "deleted", "missing"). An unusable role is never
81
81
  * pre-filled in Assign Role nor offered in its list (owner report 2026-09-28).
82
82
  */
83
83
  export function roleUsableHere(member: { role?: unknown; roleState?: unknown }): boolean {
84
- return !!member?.role && !member?.roleState;
84
+ // The server sends "ok" for a usable role (older servers send nothing).
85
+ return !!member?.role && (!member?.roleState || member.roleState === "ok");
85
86
  }
86
87
 
87
88
  /** Assign Role's empty state when the site has no role of its own yet. */
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Site access editor state (owner request 2026-09-28). One row per site of the
3
+ * organisation; a chosen site needs one of ITS OWN roles (the server refuses an
4
+ * organisation-level role for a site, because it would reach every site).
5
+ */
6
+ export type SiteAccessRole = { _id: string; name: string; type?: string };
7
+ export type SiteAccessRow = {
8
+ site: string;
9
+ name: string;
10
+ membership: { role: string; roleName?: string } | null;
11
+ roles: SiteAccessRole[];
12
+ };
13
+ export type SiteChoice = { site: string; name: string; on: boolean; role: string; roles: SiteAccessRole[]; replacedRole?: string };
14
+
15
+ /** Start from what the member has now. A current role that is not one of the site's roles is dropped. */
16
+ export function initialChoices(rows: SiteAccessRow[]): SiteChoice[] {
17
+ return (rows ?? []).map((r) => {
18
+ const current = r.membership?.role ?? "";
19
+ const valid = r.roles.some((x) => x._id === current);
20
+ return {
21
+ site: r.site, name: r.name, on: !!r.membership, role: valid ? current : "", roles: r.roles ?? [],
22
+ // their current role here is not one of this site's own roles (e.g. an
23
+ // organisation role from an invitation) - say so rather than blank it silently
24
+ ...(r.membership && !valid ? { replacedRole: r.membership.roleName || "their current role" } : {}),
25
+ };
26
+ });
27
+ }
28
+
29
+ /** Role names offered by "Same role for all selected sites". */
30
+ export function sharedRoleNames(choices: SiteChoice[]): string[] {
31
+ const names = new Set<string>();
32
+ for (const c of choices) if (c.on) for (const r of c.roles) names.add(r.name);
33
+ return [...names].sort((a, b) => a.localeCompare(b));
34
+ }
35
+
36
+ /** Give every selected site the role with this name, where that site has one. */
37
+ export function applySameRole(choices: SiteChoice[], roleName: string): SiteChoice[] {
38
+ return choices.map((c) => {
39
+ if (!c.on) return c;
40
+ const match = c.roles.find((r) => r.name === roleName);
41
+ return match ? { ...c, role: match._id } : c;
42
+ });
43
+ }
44
+
45
+ /** What to send, and what still blocks saving (a chosen site with no role). */
46
+ export function toPayload(choices: SiteChoice[]): { sites: Array<{ site: string; role: string }>; missing: string[] } {
47
+ const chosen = choices.filter((c) => c.on);
48
+ return {
49
+ sites: chosen.filter((c) => c.role).map((c) => ({ site: c.site, role: c.role })),
50
+ missing: chosen.filter((c) => !c.role).map((c) => c.name),
51
+ };
52
+ }