@7365admin1/layer-common 4.118.0 → 4.118.2

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,21 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.118.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 7beb1db: Incident Report PDF: attachments in a 2-up grid with Download links, sections flow without big gaps, and "Reported by" company name in the header and information table.
8
+
9
+ ## 4.118.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 83b3d6d: Incident Report PDF: the header now carries the report status (new optional
14
+ `status` prop on `IncidentInformationDownload`), cells use one consistent 1px
15
+ border with no half-row page splits, and the dates print as DD/MM/YYYY in
16
+ Singapore time. They printed as MM/DD/YYYY, and from a UTC server an 8 Oct date
17
+ showed as 07/10. No field removed; NRIC masking unchanged.
18
+
3
19
  ## 4.118.0
4
20
 
5
21
  ### Minor Changes
@@ -3123,11 +3139,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3123
3139
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3124
3140
  own rule from two endpoints the console already calls, unprojected:
3125
3141
 
3126
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3127
- GET /api/roles/id/:role that membership's role document
3142
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3143
+ GET /api/roles/id/:role that membership's role document
3128
3144
 
3129
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3130
- staff = member.type === "admin" && role.type === "admin"
3145
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3146
+ staff = member.type === "admin" && role.type === "admin"
3131
3147
 
3132
3148
  `role.default` is the marker because it is the only property of a platform
3133
3149
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -0,0 +1,112 @@
1
+ <template>
2
+ <div class="ir-att">
3
+ <p class="ir-att-title">Attachments</p>
4
+ <div v-if="items.length" class="ir-att-grid">
5
+ <div v-for="item in items" :key="item.id" class="ir-att-card">
6
+ <div class="ir-att-frame">
7
+ <img v-if="item.isImage" :src="item.url" :alt="item.name" />
8
+ <div v-else class="ir-att-icon">
9
+ <v-icon size="48">{{ item.icon }}</v-icon>
10
+ </div>
11
+ </div>
12
+ <p class="ir-att-name">{{ item.name }}</p>
13
+ <a class="ir-att-link" :href="item.url" target="_blank" rel="noopener">Download</a>
14
+ </div>
15
+ </div>
16
+ <p v-else class="mt-2 text-h6">No Attachments Available</p>
17
+ </div>
18
+ </template>
19
+
20
+ <script lang="ts" setup>
21
+ // Print/PDF attachments: 2 per row in fixed frames so tall phone screenshots and
22
+ // wide photos line up. The Download link is a plain <a href>, which the PDF
23
+ // renderer keeps as a live link. Files live in the public file store, so the
24
+ // link works for anyone holding the PDF.
25
+ const props = defineProps({
26
+ photos: {
27
+ type: Array as PropType<string[]>,
28
+ default: () => [],
29
+ },
30
+ });
31
+
32
+ const { getFileUrl, getFileById } = useFile();
33
+
34
+ const IMAGE = /\.(jpe?g|png|gif|webp|bmp|heic|svg)$/i;
35
+ const VIDEO = /\.(mp4|mov|avi|mkv|webm|3gp)$/i;
36
+
37
+ type Item = { id: string; name: string; url: string; isImage: boolean; icon: string };
38
+ const items = ref<Item[]>([]);
39
+
40
+ watch(
41
+ () => props.photos,
42
+ async (ids) => {
43
+ items.value = await Promise.all(
44
+ (ids ?? []).map(async (id) => {
45
+ let name = "";
46
+ try {
47
+ const res: any = await getFileById(id);
48
+ name = res?.data?.name ?? res?.name ?? "";
49
+ } catch {
50
+ // keep the link even if the name lookup fails
51
+ }
52
+ // No name known: treat as an image (that is what reports carry).
53
+ const isImage = !name || IMAGE.test(name);
54
+ return {
55
+ id,
56
+ name: name || "Attachment",
57
+ url: getFileUrl(id),
58
+ isImage,
59
+ icon: VIDEO.test(name) ? "mdi-file-video-outline" : "mdi-file-document-outline",
60
+ };
61
+ })
62
+ );
63
+ },
64
+ { immediate: true }
65
+ );
66
+ </script>
67
+
68
+ <style scoped>
69
+ .ir-att-title {
70
+ font-size: 17px;
71
+ font-weight: 600;
72
+ margin: 0 0 12px;
73
+ break-after: avoid;
74
+ page-break-after: avoid;
75
+ }
76
+ .ir-att-grid {
77
+ display: grid;
78
+ grid-template-columns: 1fr 1fr;
79
+ gap: 16px;
80
+ }
81
+ .ir-att-card {
82
+ break-inside: avoid;
83
+ page-break-inside: avoid;
84
+ border: 1px solid #8a8a8a;
85
+ padding: 8px;
86
+ min-width: 0;
87
+ }
88
+ .ir-att-frame {
89
+ height: 260px;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ background: #f5f5f5;
94
+ overflow: hidden;
95
+ }
96
+ .ir-att-frame img {
97
+ width: 100%;
98
+ height: 100%;
99
+ object-fit: contain;
100
+ }
101
+ .ir-att-name {
102
+ margin: 6px 0 2px;
103
+ font-size: 12px;
104
+ overflow-wrap: anywhere;
105
+ }
106
+ .ir-att-link {
107
+ font-size: 12px;
108
+ font-weight: 600;
109
+ color: #1565c0;
110
+ text-decoration: underline;
111
+ }
112
+ </style>
@@ -1,42 +1,48 @@
1
1
  <template>
2
- <v-row no-gutters class="px-5 pt-4">
3
- <!-- Site Information -->
4
- <p
5
- class="text-uppercase text-center w-100 text-h6 bg-error text-on-error"
6
- style="padding: 10px"
7
- >
8
- Incident Report
9
- </p>
10
- <v-row no-gutters class="mt-5 border pb-5">
11
- <p
12
- class="w-100 text-h6 pa-2 text-center bg-warning text-on-warning"
13
- >
2
+ <div class="ir-doc px-5 pt-4">
3
+ <!-- Report header: title with the status inside it, so the two never float apart. -->
4
+ <div class="ir-title bg-error text-on-error">
5
+ <p class="text-uppercase text-h6 ma-0">Incident Report</p>
6
+ <p v-if="status" class="ir-status ma-0 mt-2" :class="`ir-status--${statusKey}`">
7
+ Status: {{ String(status).toUpperCase() }}
8
+ </p>
9
+ <p v-if="companyName" class="ma-0 mt-2 ir-company">
10
+ Reported by: {{ companyName }}
11
+ </p>
12
+ </div>
13
+ <v-row no-gutters class="ir-box mt-5">
14
+ <p class="ir-section w-100 text-h6 pa-2 text-center bg-warning text-on-warning">
14
15
  Incident Information
15
16
  </p>
16
17
 
17
- <v-col cols="6" class="pa-3 border-b border-e">
18
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
18
19
  <p class="font-weight-bold">Site</p>
19
20
  <p>{{ siteName || "-" }}</p>
20
21
  </v-col>
21
- <v-col cols="6" class="pa-3 border-b">
22
+ <v-col cols="6" class="pa-3 ir-cell">
22
23
  <p class="font-weight-bold">Reference</p>
23
24
  <p>{{ incidentInformation?.siteInfo?.irNumber || "-" }}</p>
24
25
  </v-col>
25
26
 
26
- <v-col cols="6" class="pa-3 border-b border-e">
27
+ <v-col v-if="companyName" cols="12" class="pa-3 ir-cell">
28
+ <p class="font-weight-bold">Reported by (Company)</p>
29
+ <p>{{ companyName }}</p>
30
+ </v-col>
31
+
32
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
27
33
  <p class="font-weight-bold">Place of Incident</p>
28
34
  <p>
29
35
  {{ incidentInformation?.placeOfIncident?.incidentLocation || "-" }}
30
36
  </p>
31
37
  </v-col>
32
- <v-col cols="6" class="pa-3 border-b">
38
+ <v-col cols="6" class="pa-3 ir-cell">
33
39
  <p class="font-weight-bold">Type of Incident</p>
34
40
  <p>
35
41
  {{ incidentInformation?.incidentTypeAndTime?.typeOfIncident || "-" }}
36
42
  </p>
37
43
  </v-col>
38
44
 
39
- <v-col cols="6" class="pa-3 border-b border-e">
45
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
40
46
  <p class="font-weight-bold">Date of Incident</p>
41
47
  <p>
42
48
  {{
@@ -46,55 +52,58 @@
46
52
  }}
47
53
  </p>
48
54
  </v-col>
49
- <v-col cols="6" class="pa-3 border-b">
55
+ <v-col cols="6" class="pa-3 ir-cell">
50
56
  <p class="font-weight-bold">Incident Start Time</p>
51
57
  <p>
52
58
  {{ incidentInformation?.incidentTypeAndTime?.incidentStart || "-" }}
53
59
  </p>
54
60
  </v-col>
55
61
 
56
- <v-col cols="6" class="pa-3 border-b border-e">
62
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
57
63
  <p class="font-weight-bold">Time of Response (From 1st Contact)</p>
58
64
  <p>
59
65
  {{ incidentInformation?.incidentTypeAndTime?.timeOfResponse || "-" }}
60
66
  </p>
61
67
  </v-col>
62
- <v-col cols="6" class="pa-3 border-b">
68
+ <v-col cols="6" class="pa-3 ir-cell">
63
69
  <p class="font-weight-bold">Incident End Time</p>
64
70
  <p>
65
71
  {{ incidentInformation?.incidentTypeAndTime?.incidentEnd || "-" }}
66
72
  </p>
67
73
  </v-col>
68
74
 
69
- <v-col cols="6" class="pa-3 border-b border-e">
75
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
70
76
  <p class="font-weight-bold">Date of Report Submission</p>
71
- {{
72
- toLocalDate(incidentInformation?.submissionForm?.dateOfReport) || "NA"
73
- }}
77
+ <p>
78
+ {{
79
+ toLocalDate(incidentInformation?.submissionForm?.dateOfReport) ||
80
+ "NA"
81
+ }}
82
+ </p>
74
83
  </v-col>
75
- <v-col cols="6" class="pa-3 border-b">
84
+ <v-col cols="6" class="pa-3 ir-cell">
76
85
  <p class="font-weight-bold">Time of Report Submission</p>
77
86
  <p>{{ incidentInformation?.submissionForm?.time || "-" }}</p>
78
87
  </v-col>
79
88
 
80
- <v-col cols="6" class="pa-3 border-b border-e">
89
+ <v-col cols="6" class="pa-3 ir-cell ir-e">
81
90
  <p class="font-weight-bold">Report Submitted By</p>
82
91
  <p>{{ incidentInformation?.siteInfo?.submittedBy || "-" }}</p>
83
92
  </v-col>
84
- <v-col cols="6" class="pa-3 border-b">
93
+ <v-col cols="6" class="pa-3 ir-cell">
85
94
  <p class="font-weight-bold">Designation</p>
86
95
  <p>{{ incidentInformation?.siteInfo?.designation || "-" }}</p>
87
96
  </v-col>
88
97
 
89
- <v-col cols="4" class="pa-3 border-b border-e">
98
+ <v-col cols="4" class="pa-3 ir-cell ir-e">
90
99
  <p class="font-weight-bold">Name of Informant/Complainant</p>
91
100
  <p>{{ incidentInformation?.complaintInfo?.complainant || "-" }}</p>
92
101
  </v-col>
93
- <v-col cols="4" class="pa-3 border-b border-e">
102
+ <v-col cols="4" class="pa-3 ir-cell ir-e">
94
103
  <p class="font-weight-bold">Contact No.</p>
95
104
  <p>{{ incidentInformation?.complaintInfo?.contact || "-" }}</p>
96
105
  </v-col>
97
- <v-col cols="4" class="pa-3 border-b">
106
+ <v-col cols="4" class="pa-3 ir-cell">
98
107
  <p class="font-weight-bold">NRIC/WP No.</p>
99
108
  <p>
100
109
  {{
@@ -105,17 +114,17 @@
105
114
  </p>
106
115
  </v-col>
107
116
 
108
- <v-col cols="4" class="pa-3 border-b border-e">
117
+ <v-col cols="4" class="pa-3 ir-cell ir-e">
109
118
  <p class="font-weight-bold">
110
119
  Name of Recipient of Complaint (1st Contact)
111
120
  </p>
112
121
  <p>{{ incidentInformation?.recipientOfComplaint?.recipient || "-" }}</p>
113
122
  </v-col>
114
- <v-col cols="4" class="pa-3 border-b border-e">
123
+ <v-col cols="4" class="pa-3 ir-cell ir-e">
115
124
  <p class="font-weight-bold">Contact No.</p>
116
125
  <p>{{ incidentInformation?.recipientOfComplaint?.contact || "-" }}</p>
117
126
  </v-col>
118
- <v-col cols="4" class="pa-3 border-b">
127
+ <v-col cols="4" class="pa-3 ir-cell">
119
128
  <p class="font-weight-bold">NRIC/WP No.</p>
120
129
  <p>
121
130
  {{
@@ -126,7 +135,7 @@
126
135
  </p>
127
136
  </v-col>
128
137
 
129
- <v-col cols="12" class="pa-3 border-b border-e">
138
+ <v-col cols="12" class="pa-3 ir-cell">
130
139
  <p class="font-weight-bold">Complaint Received through</p>
131
140
  <p
132
141
  v-if="
@@ -142,7 +151,7 @@
142
151
  }}
143
152
  </p>
144
153
  </v-col>
145
- <v-col cols="12" class="pa-3">
154
+ <v-col cols="12" class="pa-3 ir-cell ir-last">
146
155
  <p class="font-weight-bold">Brief Description</p>
147
156
  <p>
148
157
  {{
@@ -151,7 +160,7 @@
151
160
  </p>
152
161
  </v-col>
153
162
  </v-row>
154
- </v-row>
163
+ </div>
155
164
  </template>
156
165
 
157
166
  <script lang="ts" setup>
@@ -162,8 +171,36 @@ const props = defineProps({
162
171
  type: Object as PropType<Record<string, any> | null>,
163
172
  required: true,
164
173
  },
174
+ // Optional report status (pending/approved/rejected), printed inside the header.
175
+ status: {
176
+ type: String,
177
+ default: "",
178
+ },
179
+ // The report's `organization` id: the company that filed it.
180
+ organizationId: {
181
+ type: String,
182
+ default: "",
183
+ },
165
184
  });
166
185
 
186
+ const { getById: getOrgById } = useOrg();
187
+ const companyName = ref("");
188
+ watch(
189
+ () => props.organizationId,
190
+ async (id) => {
191
+ companyName.value = "";
192
+ if (!id) return;
193
+ try {
194
+ companyName.value = incidentCompanyName(await getOrgById(id));
195
+ } catch {
196
+ // Not readable by this viewer: print nothing rather than a wrong name.
197
+ }
198
+ },
199
+ { immediate: true }
200
+ );
201
+
202
+ const statusKey = computed(() => String(props.status || "").toLowerCase());
203
+
167
204
  // utilities
168
205
  const { getSiteById } = useSiteSettings();
169
206
  const route = useRoute();
@@ -204,11 +241,16 @@ watch(
204
241
  { immediate: true }
205
242
  );
206
243
 
244
+ // DD/MM/YYYY in Singapore time. The PDF is rendered on a server whose clock is
245
+ // UTC, so without the zone an 8 Oct (SGT) date prints as 07/10.
207
246
  function toLocalDate(utcString: string) {
208
- return new Date(utcString).toLocaleString("en-US", {
247
+ const d = new Date(utcString);
248
+ if (!utcString || isNaN(d.getTime())) return "";
249
+ return d.toLocaleDateString("en-GB", {
209
250
  year: "numeric",
210
251
  month: "2-digit",
211
252
  day: "2-digit",
253
+ timeZone: "Asia/Singapore",
212
254
  });
213
255
  }
214
256
 
@@ -218,3 +260,52 @@ const maskNRIC = (value: any) => {
218
260
  return value.replace(/.(?=.{3})/g, "*");
219
261
  };
220
262
  </script>
263
+
264
+ <style scoped>
265
+ /* One consistent 1px grey rule for every cell edge; rows never split across pages. */
266
+ .ir-title {
267
+ text-align: center;
268
+ padding: 10px;
269
+ }
270
+ .ir-status {
271
+ display: inline-block;
272
+ background: #fff;
273
+ color: #b26a00;
274
+ border: 1px solid currentColor;
275
+ border-radius: 4px;
276
+ padding: 2px 14px;
277
+ font-size: 0.95rem;
278
+ font-weight: 700;
279
+ letter-spacing: 0.03em;
280
+ }
281
+ .ir-company {
282
+ font-size: 1rem;
283
+ font-weight: 600;
284
+ }
285
+ .ir-status--approved { color: #2e7d32; }
286
+ .ir-status--rejected { color: #c62828; }
287
+ .ir-box {
288
+ border: 1px solid #8a8a8a;
289
+ }
290
+ .ir-cell {
291
+ border-bottom: 1px solid #8a8a8a;
292
+ break-inside: avoid;
293
+ page-break-inside: avoid;
294
+ }
295
+ .ir-e {
296
+ border-right: 1px solid #8a8a8a;
297
+ }
298
+ .ir-last {
299
+ border-bottom: 0;
300
+ /* The long free-text answer may flow onto the next page, but never leave
301
+ one or two stray lines behind. */
302
+ break-inside: auto;
303
+ page-break-inside: auto;
304
+ orphans: 3;
305
+ widows: 3;
306
+ }
307
+ .ir-section {
308
+ break-after: avoid;
309
+ border-bottom: 1px solid #8a8a8a;
310
+ }
311
+ </style>
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.118.0",
5
+ "version": "4.118.2",
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.",
@@ -0,0 +1,7 @@
1
+ // The company that filed an incident report = the organisation stored on the
2
+ // report (the filer's own membership org: security agency, or the client's own
3
+ // staff org). Returns "" when it cannot be resolved so nothing wrong is printed.
4
+ export function incidentCompanyName(org: unknown): string {
5
+ const name = (org as { name?: unknown } | null | undefined)?.name;
6
+ return typeof name === "string" ? name.trim() : "";
7
+ }