@7365admin1/layer-common 4.118.1 → 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,11 @@
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
+
3
9
  ## 4.118.1
4
10
 
5
11
  ### Patch Changes
@@ -3133,11 +3139,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3133
3139
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3134
3140
  own rule from two endpoints the console already calls, unprojected:
3135
3141
 
3136
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3137
- 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
3138
3144
 
3139
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3140
- 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"
3141
3147
 
3142
3148
  `role.default` is the marker because it is the only property of a platform
3143
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>
@@ -6,6 +6,9 @@
6
6
  <p v-if="status" class="ir-status ma-0 mt-2" :class="`ir-status--${statusKey}`">
7
7
  Status: {{ String(status).toUpperCase() }}
8
8
  </p>
9
+ <p v-if="companyName" class="ma-0 mt-2 ir-company">
10
+ Reported by: {{ companyName }}
11
+ </p>
9
12
  </div>
10
13
  <v-row no-gutters class="ir-box mt-5">
11
14
  <p class="ir-section w-100 text-h6 pa-2 text-center bg-warning text-on-warning">
@@ -21,6 +24,11 @@
21
24
  <p>{{ incidentInformation?.siteInfo?.irNumber || "-" }}</p>
22
25
  </v-col>
23
26
 
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
+
24
32
  <v-col cols="6" class="pa-3 ir-cell ir-e">
25
33
  <p class="font-weight-bold">Place of Incident</p>
26
34
  <p>
@@ -168,8 +176,29 @@ const props = defineProps({
168
176
  type: String,
169
177
  default: "",
170
178
  },
179
+ // The report's `organization` id: the company that filed it.
180
+ organizationId: {
181
+ type: String,
182
+ default: "",
183
+ },
171
184
  });
172
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
+
173
202
  const statusKey = computed(() => String(props.status || "").toLowerCase());
174
203
 
175
204
  // utilities
@@ -249,6 +278,10 @@ const maskNRIC = (value: any) => {
249
278
  font-weight: 700;
250
279
  letter-spacing: 0.03em;
251
280
  }
281
+ .ir-company {
282
+ font-size: 1rem;
283
+ font-weight: 600;
284
+ }
252
285
  .ir-status--approved { color: #2e7d32; }
253
286
  .ir-status--rejected { color: #c62828; }
254
287
  .ir-box {
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.1",
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
+ }