@7365admin1/layer-common 4.109.1 → 4.110.0

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,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.110.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a2575af: Occupancy & tenancies (1PAM): new UnitTenancies screen (ending within 30/60/90 days or ended; Renew; End tenancy with a checked per-tenant access list), composable calls, "Tenancy Ending" notifications open Occupancy Mgmt > Tenancies; Edit Unit tab "Tenancy & Documents" with optional, ordered tenancy dates and plain help text; menu/dashboard label "Occupancy Mgmt/Management".
8
+
3
9
  ## 4.109.1
4
10
 
5
11
  ### Patch Changes
@@ -11,7 +11,7 @@
11
11
  <v-tab value="residents">Residents/Tenants</v-tab>
12
12
  <v-tab value="vehicles">Vehicles</v-tab>
13
13
  <v-tab value="management">Management</v-tab>
14
- <v-tab value="others">Others</v-tab>
14
+ <v-tab value="others">Tenancy &amp; Documents</v-tab>
15
15
  </v-tabs>
16
16
  </v-toolbar>
17
17
  <v-card-text style="max-height: 100vh; min-height: 60svh; overflow-y: auto">
@@ -193,10 +193,9 @@
193
193
  <v-row>
194
194
  <v-col cols="12" md="6" class="mt-2">
195
195
  <v-row no-gutters>
196
- <InputLabel class="text-capitalize" title="Company Name" required />
196
+ <InputLabel class="text-capitalize" title="Company Name (commercial units)" />
197
197
  <v-col cols="12">
198
- <v-text-field v-model="buildingUnit.companyName" density="comfortable"
199
- :rules="[requiredRule]"></v-text-field>
198
+ <v-text-field v-model="buildingUnit.companyName" density="comfortable"></v-text-field>
200
199
  </v-col>
201
200
  </v-row>
202
201
  </v-col>
@@ -213,21 +212,26 @@
213
212
  </v-col>
214
213
 
215
214
  <v-col cols="12">
215
+ <p class="text-body-2 text-medium-emphasis mb-0">
216
+ If the unit is rented out, enter the tenancy dates from the tenancy agreement. You are reminded 30 days
217
+ and 7 days before the tenancy ends, and on the day, and can renew or end it under Occupancy Mgmt &gt;
218
+ Tenancies. Leave both empty for an owner-occupied unit.
219
+ </p>
216
220
  <v-row>
217
221
  <v-col cols="12" md="6" class="mt-2">
218
222
  <v-row no-gutters>
219
- <InputLabel class="text-capitalize" title="Lease Start" required />
223
+ <InputLabel class="text-capitalize" title="Tenancy Start" />
220
224
  <v-col cols="12">
221
- <InputDateTimePicker ref="startDateRef" v-model="buildingUnit.leaseStart" :rules="[requiredRule]" />
225
+ <InputDateTimePicker ref="startDateRef" v-model="buildingUnit.leaseStart" />
222
226
  </v-col>
223
227
  </v-row>
224
228
  </v-col>
225
229
 
226
230
  <v-col cols="12" md="6" class="mt-2">
227
231
  <v-row no-gutters>
228
- <InputLabel class="text-capitalize" title="Lease End" required />
232
+ <InputLabel class="text-capitalize" title="Tenancy End" />
229
233
  <v-col cols="12">
230
- <InputDateTimePicker ref="endDateRef" v-model="buildingUnit.leaseEnd" :rules="[requiredRule]" />
234
+ <InputDateTimePicker ref="endDateRef" v-model="buildingUnit.leaseEnd" />
231
235
  </v-col>
232
236
  </v-row>
233
237
  </v-col>
@@ -507,6 +511,21 @@ const hasChanges = computed(() => {
507
511
  });
508
512
 
509
513
  async function submit() {
514
+ message.value = "";
515
+ // Tenancy dates are optional (an owner-occupied unit has none), but an end
516
+ // before its start, or a start with no end, would never remind anyone.
517
+ const leaseStart = String(buildingUnit.value.leaseStart || "");
518
+ const leaseEnd = String(buildingUnit.value.leaseEnd || "");
519
+ if (leaseStart && !leaseEnd) {
520
+ message.value = "Enter the tenancy end date, or clear the start date.";
521
+ tab.value = "others";
522
+ return;
523
+ }
524
+ if (leaseStart && leaseEnd && leaseEnd <= leaseStart) {
525
+ message.value = "The tenancy end date must be after the start date.";
526
+ tab.value = "others";
527
+ return;
528
+ }
510
529
  disable.value = true;
511
530
  try {
512
531
 
@@ -0,0 +1,291 @@
1
+ <template>
2
+ <!--
3
+ Tenancies (owner request, 1PAM meeting 2026-09-22 / build 2026-10-07).
4
+
5
+ Units whose tenancy ends soon or has ended, from the Lease Start / End on
6
+ the unit. The managing agent is reminded 30 days and 7 days before, and on
7
+ the day; from here a tenancy is renewed (new dates) or ended - ending first
8
+ shows exactly what access each tenant still has, and nothing is removed
9
+ until the MA confirms. Who may do this is decided by the server.
10
+ -->
11
+ <v-row no-gutters>
12
+ <v-col cols="12">
13
+ <PageHeader title="Tenancies" />
14
+ <p class="tn-intro">
15
+ Units whose tenancy ends within the period you choose, or has already ended. You are reminded 30 days and
16
+ 7 days before, and on the day. <strong>Renew</strong> sets new tenancy dates. <strong>End tenancy</strong>
17
+ shows the tenants' access at this estate (resident app, door access, access cards, vehicles) and removes it
18
+ after you confirm. Tenancy dates are set on each unit (Occupancy Mgmt &gt; Units &gt; unit &gt; Tenancy &amp; Documents).
19
+ </p>
20
+ </v-col>
21
+
22
+ <v-col cols="12">
23
+ <AppCard class="pa-4">
24
+ <div class="d-flex align-center flex-wrap ga-2 mb-3">
25
+ <v-btn-toggle v-model="days" mandatory density="comfortable" variant="outlined" divided data-test="tn-days" @update:model-value="load">
26
+ <v-btn :value="30" class="text-none">Next 30 days</v-btn>
27
+ <v-btn :value="60" class="text-none">Next 60 days</v-btn>
28
+ <v-btn :value="90" class="text-none">Next 90 days</v-btn>
29
+ </v-btn-toggle>
30
+ <v-spacer />
31
+ <v-btn variant="text" icon="mdi-refresh" aria-label="Refresh" :loading="loading" @click="load" />
32
+ </div>
33
+
34
+ <v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-3">{{ error }}</v-alert>
35
+
36
+ <div v-if="!loading && !items.length && !error" class="tn-empty" data-test="tn-empty">
37
+ No tenancy ends in the next {{ days }} days, and none has ended without being renewed or closed.
38
+ </div>
39
+
40
+ <div v-for="item in items" :key="item._id" class="tn-row" data-test="tn-row">
41
+ <div class="tn-row__main">
42
+ <div class="tn-row__unit">{{ item.unit }}</div>
43
+ <div class="tn-sub">
44
+ Tenancy {{ item.startLabel ? `${item.startLabel} - ` : "ends " }}{{ item.endLabel }}
45
+ </div>
46
+ <div class="tn-sub">
47
+ Tenants:
48
+ <template v-if="item.tenants.length">{{ item.tenants.map((t: any) => t.name).join(", ") }}</template>
49
+ <template v-else>none registered</template>
50
+ </div>
51
+ </div>
52
+ <v-chip size="small" :color="chip(item).color" variant="tonal" class="mr-2" data-test="tn-chip">{{ chip(item).text }}</v-chip>
53
+ <div class="tn-row__actions">
54
+ <v-btn size="small" variant="outlined" class="text-none" data-test="tn-renew" @click="openRenew(item)">Renew</v-btn>
55
+ <v-btn size="small" variant="flat" color="error" class="text-none" data-test="tn-end" @click="openEnd(item)">End tenancy</v-btn>
56
+ </div>
57
+ </div>
58
+ </AppCard>
59
+ </v-col>
60
+ </v-row>
61
+
62
+ <!-- Renew -->
63
+ <v-dialog v-model="renewOpen" max-width="520" persistent>
64
+ <v-card class="screen-modal" data-test="tn-renew-dialog">
65
+ <v-card-title class="d-flex align-center">
66
+ Renew tenancy - {{ current?.unit }}
67
+ <v-spacer />
68
+ <v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" :disabled="saving" @click="renewOpen = false" />
69
+ </v-card-title>
70
+ <v-card-text class="screen-modal__body">
71
+ <p class="tn-intro">
72
+ Current tenancy ends {{ current?.endLabel }}. Enter the new tenancy dates from the renewed agreement. The
73
+ tenants keep their access, and the reminders start again for the new end date.
74
+ </p>
75
+ <label class="tn-label" for="tn-start">New tenancy start (optional)</label>
76
+ <input id="tn-start" v-model="renewStart" type="date" class="tn-date" data-test="tn-start" />
77
+ <label class="tn-label" for="tn-end">New tenancy end</label>
78
+ <input id="tn-end" v-model="renewEnd" type="date" class="tn-date" :min="tomorrow" data-test="tn-end-date" />
79
+ <div v-if="renewError" class="tn-error">{{ renewError }}</div>
80
+ </v-card-text>
81
+ <div class="screen-modal__footer">
82
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="renewOpen = false">Cancel</v-btn>
83
+ <v-btn class="screen-btn-primary" variant="flat" :loading="saving" :disabled="!renewEnd" data-test="tn-renew-confirm" @click="confirmRenew">
84
+ Save new dates
85
+ </v-btn>
86
+ </div>
87
+ </v-card>
88
+ </v-dialog>
89
+
90
+ <!-- End tenancy -->
91
+ <v-dialog v-model="endOpen" max-width="640" persistent>
92
+ <v-card class="screen-modal" data-test="tn-end-dialog">
93
+ <v-card-title class="d-flex align-center">
94
+ End tenancy - {{ current?.unit }}
95
+ <v-spacer />
96
+ <v-btn variant="text" icon="mdi-close" class="screen-modal__close" aria-label="Close" :disabled="saving" @click="endOpen = false" />
97
+ </v-card-title>
98
+ <v-card-text class="screen-modal__body">
99
+ <p class="tn-intro">
100
+ Ending the tenancy removes each tenant's access at this estate only. Their account, and anything at other
101
+ estates, is not touched. The unit's owner is not affected.
102
+ </p>
103
+ <div v-if="previewLoading" class="tn-sub">Checking the tenants' access...</div>
104
+ <div v-else-if="previewError" class="tn-error">{{ previewError }}</div>
105
+ <template v-else-if="preview">
106
+ <div v-if="!preview.access.length" class="tn-sub">
107
+ No tenants are registered on this unit. Ending clears the tenancy dates only.
108
+ </div>
109
+ <div v-for="a in preview.access" :key="a.person._id" class="tn-tenant" data-test="tn-tenant">
110
+ <div class="tn-row__unit">{{ a.person.name }}</div>
111
+ <ul class="tn-list">
112
+ <li>Registration for this unit{{ a.appAccess ? " and resident app access" : "" }} - removed</li>
113
+ <li v-if="a.keepsOtherUnitHere">Also lives in another unit here - keeps resident app sign-in, door access and cards</li>
114
+ <li v-else-if="a.doorAccess">Door access (face / QR): {{ a.doorAccess }} - removed from the readers</li>
115
+ <li v-if="!a.keepsOtherUnitHere && a.accessCards">Access cards: {{ a.accessCards }} - deactivated (collect the physical card)</li>
116
+ <li v-if="a.vehicles.length">Vehicles: {{ a.vehicles.join(", ") }} - removed (and from the car park cameras)</li>
117
+ </ul>
118
+ </div>
119
+ <v-checkbox v-model="understood" density="compact" hide-details class="mt-2" data-test="tn-understood"
120
+ label="I have checked the list above. Remove this access and end the tenancy." />
121
+ </template>
122
+ <div v-if="endResult" class="tn-result" data-test="tn-end-result">
123
+ <div>{{ endResult.summary }}</div>
124
+ <ul v-if="endResult.failures.length" class="tn-list tn-error">
125
+ <li v-for="f in endResult.failures" :key="f">{{ f }} - please remove it by hand or try again.</li>
126
+ </ul>
127
+ </div>
128
+ </v-card-text>
129
+ <div class="screen-modal__footer">
130
+ <v-btn class="screen-btn-ghost" variant="outlined" :disabled="saving" @click="endOpen = false">{{ endResult ? "Close" : "Cancel" }}</v-btn>
131
+ <v-btn v-if="!endResult" color="error" variant="flat" class="text-none" :loading="saving" :disabled="!preview || !understood" data-test="tn-end-confirm" @click="confirmEnd">
132
+ End tenancy
133
+ </v-btn>
134
+ </div>
135
+ </v-card>
136
+ </v-dialog>
137
+
138
+ <Snackbar v-model="snack" :text="snackText" :color="snackColor" />
139
+ </template>
140
+
141
+ <script lang="ts" setup>
142
+ const props = defineProps({
143
+ siteId: { type: String, required: true },
144
+ });
145
+
146
+ const { getTenancies, renewTenancy, getEndTenancyPreview, endTenancy } = useBuildingUnit();
147
+
148
+ const days = ref(60);
149
+ const items = ref<Record<string, any>[]>([]);
150
+ const loading = ref(false);
151
+ const error = ref("");
152
+ const current = ref<Record<string, any> | null>(null);
153
+ const saving = ref(false);
154
+
155
+ const renewOpen = ref(false);
156
+ const renewStart = ref("");
157
+ const renewEnd = ref("");
158
+ const renewError = ref("");
159
+
160
+ const endOpen = ref(false);
161
+ const preview = ref<Record<string, any> | null>(null);
162
+ const previewLoading = ref(false);
163
+ const previewError = ref("");
164
+ const understood = ref(false);
165
+ const endResult = ref<{ summary: string; failures: string[] } | null>(null);
166
+
167
+ const snack = ref(false);
168
+ const snackText = ref("");
169
+ const snackColor = ref("success");
170
+
171
+ const tomorrow = computed(() => new Date(Date.now() + 864e5).toISOString().slice(0, 10));
172
+
173
+ const message = (e: any, fallback: string) => e?.data?.message || e?.response?._data?.message || fallback;
174
+
175
+ function chip(item: Record<string, any>) {
176
+ if (item.stage === "ended") return { text: "Ended", color: "error" };
177
+ const d = Number(item.daysLeft);
178
+ if (d <= 7) return { text: d <= 0 ? "Ends today" : `Ends in ${d} day${d === 1 ? "" : "s"}`, color: "warning" };
179
+ return { text: `Ends in ${d} days`, color: "info" };
180
+ }
181
+
182
+ async function load() {
183
+ if (!props.siteId) return;
184
+ loading.value = true;
185
+ error.value = "";
186
+ try {
187
+ const res = await getTenancies(props.siteId, days.value);
188
+ items.value = res?.items ?? [];
189
+ } catch (e: any) {
190
+ items.value = [];
191
+ error.value = message(e, "Tenancies could not be loaded. Please try again.");
192
+ } finally {
193
+ loading.value = false;
194
+ }
195
+ }
196
+
197
+ function openRenew(item: Record<string, any>) {
198
+ current.value = item;
199
+ renewStart.value = "";
200
+ renewEnd.value = "";
201
+ renewError.value = "";
202
+ renewOpen.value = true;
203
+ }
204
+
205
+ async function confirmRenew() {
206
+ if (!current.value) return;
207
+ renewError.value = "";
208
+ if (renewStart.value && renewEnd.value <= renewStart.value) {
209
+ renewError.value = "The tenancy end date must be after the start date.";
210
+ return;
211
+ }
212
+ saving.value = true;
213
+ try {
214
+ // Stored like the Edit Unit form stores them (local date-time, the site's time): the tenancy runs to the end of its last day.
215
+ await renewTenancy(current.value._id, {
216
+ ...(renewStart.value ? { leaseStart: `${renewStart.value}T00:00` } : {}),
217
+ leaseEnd: `${renewEnd.value}T23:59`,
218
+ });
219
+ renewOpen.value = false;
220
+ say(`Tenancy for ${current.value.unit} renewed to ${renewEnd.value}.`);
221
+ await load();
222
+ } catch (e: any) {
223
+ renewError.value = message(e, "The tenancy could not be renewed. Please try again.");
224
+ } finally {
225
+ saving.value = false;
226
+ }
227
+ }
228
+
229
+ async function openEnd(item: Record<string, any>) {
230
+ current.value = item;
231
+ preview.value = null;
232
+ previewError.value = "";
233
+ understood.value = false;
234
+ endResult.value = null;
235
+ endOpen.value = true;
236
+ previewLoading.value = true;
237
+ try {
238
+ preview.value = await getEndTenancyPreview(item._id);
239
+ } catch (e: any) {
240
+ previewError.value = message(e, "The tenants' access could not be checked. Please try again.");
241
+ } finally {
242
+ previewLoading.value = false;
243
+ }
244
+ }
245
+
246
+ async function confirmEnd() {
247
+ if (!current.value) return;
248
+ saving.value = true;
249
+ try {
250
+ const r = await endTenancy(current.value._id);
251
+ const parts = [
252
+ `${r.tenants} tenant${r.tenants === 1 ? "" : "s"} removed from ${r.unit}`,
253
+ r.doorAccess ? `${r.doorAccess} door access` : "",
254
+ r.accessCards ? `${r.accessCards} access card${r.accessCards === 1 ? "" : "s"} deactivated` : "",
255
+ r.vehicles ? `${r.vehicles} vehicle${r.vehicles === 1 ? "" : "s"} removed` : "",
256
+ ].filter(Boolean);
257
+ endResult.value = { summary: `Tenancy ended. ${parts.join(", ")}.`, failures: r.failures ?? [] };
258
+ if (!endResult.value.failures.length) say(endResult.value.summary);
259
+ await load();
260
+ } catch (e: any) {
261
+ previewError.value = message(e, "The tenancy could not be ended. Nothing was changed. Please try again.");
262
+ } finally {
263
+ saving.value = false;
264
+ }
265
+ }
266
+
267
+ function say(text: string, color = "success") {
268
+ snackText.value = text;
269
+ snackColor.value = color;
270
+ snack.value = true;
271
+ }
272
+
273
+ watch(() => props.siteId, load, { immediate: true });
274
+ </script>
275
+
276
+ <style scoped>
277
+ .tn-intro { margin: 0 0 12px; color: var(--muted, inherit); }
278
+ .tn-label { display: block; font-weight: 600; margin: 10px 0 4px; }
279
+ .tn-date { border: 1px solid var(--line, #ccc); border-radius: 6px; padding: 8px 10px; color: inherit; background: transparent; }
280
+ .tn-sub { color: var(--muted, inherit); font-size: 14px; margin-top: 2px; }
281
+ .tn-error { color: var(--err, #b00020); margin-top: 6px; }
282
+ .tn-empty { padding: 24px 8px; text-align: center; color: var(--muted, inherit); }
283
+ .tn-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; padding: 12px 0; border-bottom: 1px solid var(--line, #eee); }
284
+ .tn-row__main { flex: 1 1 260px; min-width: 0; }
285
+ .tn-row__unit { font-weight: 600; }
286
+ .tn-row__actions { display: flex; gap: 8px; }
287
+ .tn-tenant { padding: 10px 0; border-bottom: 1px solid var(--line, #eee); }
288
+ .tn-list { margin: 4px 0 0 18px; padding: 0; }
289
+ .tn-list li { margin: 3px 0; }
290
+ .tn-result { margin-top: 12px; padding: 10px 12px; border: 1px solid var(--line, #ddd); border-radius: 8px; }
291
+ </style>
@@ -102,7 +102,28 @@ export default function useBuildingUnit() {
102
102
  directorName: "",
103
103
  });
104
104
 
105
+ // Unit tenancies (2026-10-07): the Tenancies list, Renew and End tenancy.
106
+ function getTenancies(site: string, days = 60) {
107
+ return useNuxtApp().$api<Record<string, any>>("/api/building-units/tenancies", {
108
+ method: "GET",
109
+ query: { site, days },
110
+ });
111
+ }
112
+ function renewTenancy(id: string, body: { leaseStart?: string; leaseEnd: string }) {
113
+ return useNuxtApp().$api<Record<string, any>>(`/api/building-units/${id}/tenancy/renew`, { method: "POST", body });
114
+ }
115
+ function getEndTenancyPreview(id: string) {
116
+ return useNuxtApp().$api<Record<string, any>>(`/api/building-units/${id}/tenancy/end-preview`, { method: "GET" });
117
+ }
118
+ function endTenancy(id: string) {
119
+ return useNuxtApp().$api<Record<string, any>>(`/api/building-units/${id}/tenancy/end`, { method: "POST" });
120
+ }
121
+
105
122
  return {
123
+ getTenancies,
124
+ renewTenancy,
125
+ getEndTenancyPreview,
126
+ endTenancy,
106
127
  categories,
107
128
  region,
108
129
  getAll,
@@ -59,7 +59,7 @@ const dashboardDataMap: Record<string, ModuleData> = {
59
59
  color: '#FFA726',
60
60
  },
61
61
  {
62
- title: 'Building Management',
62
+ title: 'Occupancy Management',
63
63
  value: 35,
64
64
  percentage: 15.4,
65
65
  isPositive: true,
@@ -95,7 +95,7 @@ const dashboardDataMap: Record<string, ModuleData> = {
95
95
  'Feedback',
96
96
  'Work Orders',
97
97
  'Vehicle Management',
98
- 'Building Management',
98
+ 'Occupancy Management',
99
99
  'Visitor Management',
100
100
  'Incident Reports',
101
101
  'Guest Management',
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.109.1",
5
+ "version": "4.110.0",
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.",
@@ -57,6 +57,9 @@ export const NAV_SECTIONS: Record<string, string> = {
57
57
 
58
58
  // PROPERTY
59
59
  "Vehicle Mgmt": "PROPERTY",
60
+ // Renamed "Occupancy Mgmt" for the client (1PAM, 2026-10-07); the old title
61
+ // stays mapped so an app still pinned to an older menu groups the same.
62
+ "Occupancy Mgmt": "PROPERTY",
60
63
  "Building Mgmt": "PROPERTY",
61
64
  "Visitor Mgmt": "PROPERTY",
62
65
  "Pass & Key Mgmt": "PROPERTY",
@@ -188,6 +188,7 @@ const SITE_PAGES: Record<string, TSitePage[]> = {
188
188
  facilityBooking: [{ apps: ["property-management"], page: () => "facility-booking" }],
189
189
  onlineForm: [{ apps: ["property-management"], page: () => "online-forms" }],
190
190
  documentExpiry: [{ apps: ["property-management"], page: () => "document-mgmt" }],
191
+ tenancyExpiry: [{ apps: ["property-management"], page: () => "building-mgmt/tenancies" }],
191
192
  emergencyContact: [{ apps: ["property-management"], page: () => "emergency-contacts" }],
192
193
  incidentReport: [{ apps: ["security", "property-management"], page: withId("incident-reports") }],
193
194
  visitors: [