@burdenoff/microfe-assethandler 2026.1108.1 → 2026.1111.1

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.
@@ -5,6 +5,29 @@
5
5
  * `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public
6
6
  * share links via `createFileShareLink`. All entries are live data.
7
7
  *
8
+ * Sharing follows the files service's rules, which depend on visibility:
9
+ *
10
+ * - PRIVATE and TEAM files can only be shared by their uploader or someone
11
+ * holding a SHARE/MANAGE grant or a MANAGER share. For these the panel asks
12
+ * `getUserFilePermissions(fileId).canShare` before minting a link. The check
13
+ * runs lazily (on each desktop click and each time the mobile menu opens), so
14
+ * a long file list never fans out into one permission query per row.
15
+ * - WORKSPACE and PUBLIC files are governed only by the gateway's
16
+ * `@rbac(share, file)` check. `canShare` reflects explicit grants alone for
17
+ * these files, so it is not consulted: the panel calls `createFileShareLink`
18
+ * directly and treats its FORBIDDEN as the denial.
19
+ *
20
+ * The two denials arrive differently and get different copy. The gateway's
21
+ * role check answers HTTP 403 (an Apollo `ServerError`), so the caller is told
22
+ * their workspace role cannot share. The service's per-file rule answers HTTP
23
+ * 200 with a `FORBIDDEN` GraphQL error, so the caller is told who can share
24
+ * the file. A 503 `RBAC_SERVICE_UNAVAILABLE` is an outage, not a denial.
25
+ *
26
+ * `getUserFilePermissions` needs its own `@rbac(read, file-permission)`, which
27
+ * some roles lack. A FORBIDDEN from that query, from either source, means
28
+ * "unknown", never "cannot share": the panel falls through and lets the share
29
+ * mutation decide.
30
+ *
8
31
  * `entityType`/`entityId` scope the panel to the record it is mounted on and
9
32
  * are used for context labelling; uploaded files land in the workspace file
10
33
  * store so they can be shared and reused.
@@ -1,12 +1,13 @@
1
1
  import { notify as e } from "../utils/toast.js";
2
- import { useMemo as t, useRef as n, useState as r } from "react";
3
- import { Download as i, File as a, FileArchive as o, FileAudio as s, FileCode2 as c, FileSpreadsheet as l, FileText as u, FileVideo as d, Image as f, Link2 as p, Loader2 as m, MoreHorizontal as h, Paperclip as g, Upload as _ } from "lucide-react";
4
- import { gql as v } from "@apollo/client";
5
- import { useMutation as y, useQuery as b } from "@apollo/client/react";
6
- import { jsx as x, jsxs as S } from "react/jsx-runtime";
7
- import { Button as C, Card as w, CardContent as T, CardHeader as E, CardTitle as D, DropdownMenu as O, DropdownMenuContent as k, DropdownMenuItem as A, DropdownMenuTrigger as j, IllustratedEmptyState as M } from "@burdenoff/fe-libs/ui";
2
+ import { getForbiddenSource as t, isForbiddenError as n } from "../utils/graphqlErrors.js";
3
+ import { useCallback as r, useMemo as i, useRef as a, useState as o } from "react";
4
+ import { Download as s, File as c, FileArchive as l, FileAudio as u, FileCode2 as d, FileSpreadsheet as f, FileText as p, FileVideo as m, Image as h, Link2 as g, Loader2 as _, Lock as v, MoreHorizontal as y, Paperclip as b, Upload as ee } from "lucide-react";
5
+ import { gql as x } from "@apollo/client";
6
+ import { useApolloClient as te, useMutation as S, useQuery as C } from "@apollo/client/react";
7
+ import { jsx as w, jsxs as T } from "react/jsx-runtime";
8
+ import { Button as ne, Card as E, CardContent as D, CardHeader as O, CardTitle as k, DropdownMenu as A, DropdownMenuContent as j, DropdownMenuItem as M, DropdownMenuTrigger as re, IllustratedEmptyState as ie } from "@burdenoff/fe-libs/ui";
8
9
  // src/assethandler/components/AttachmentsPanel.tsx
9
- var N = v`
10
+ var ae = x`
10
11
  query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {
11
12
  getFiles(pagination: $pagination, sort: $sort) {
12
13
  total
@@ -19,10 +20,11 @@ var N = v`
19
20
  size
20
21
  downloadUrl
21
22
  createdAt
23
+ visibility
22
24
  }
23
25
  }
24
26
  }
25
- `, P = v`
27
+ `, oe = x`
26
28
  mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {
27
29
  initiateUpload(input: $input) {
28
30
  uploadUrl
@@ -31,7 +33,7 @@ var N = v`
31
33
  }
32
34
  }
33
35
  }
34
- `, F = v`
36
+ `, se = x`
35
37
  mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {
36
38
  completeUpload(input: $input) {
37
39
  fileId
@@ -41,15 +43,40 @@ var N = v`
41
43
  }
42
44
  }
43
45
  }
44
- `, I = v`
46
+ `, ce = x`
45
47
  mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {
46
48
  createFileShareLink(input: $input) {
47
49
  id
48
50
  shareUrl
49
51
  }
50
52
  }
53
+ `, N = x`
54
+ query AssetHandlerUserFilePermissions($fileId: ID!) {
55
+ getUserFilePermissions(fileId: $fileId) {
56
+ fileId
57
+ canShare
58
+ }
59
+ }
51
60
  `;
52
- function L(e) {
61
+ function P(e) {
62
+ return e === "PRIVATE" || e === "TEAM";
63
+ }
64
+ var F = "You can't share this file", I = "Only the uploader, or someone with share or manage access to this file, can create a share link. Ask one of them to share it or to grant you access.", L = "Your workspace role doesn't allow sharing files. Ask a workspace admin for share access.", R = "You don't have permission to share this file. Ask the file's uploader or a workspace admin.";
65
+ function z(e) {
66
+ return e === "denied-role" || e === "denied-restricted" || e === "denied-other";
67
+ }
68
+ function B(e) {
69
+ switch (e) {
70
+ case "denied-role": return L;
71
+ case "denied-restricted": return I;
72
+ case "denied-other": return R;
73
+ }
74
+ }
75
+ function V(e, n) {
76
+ let r = t(e);
77
+ return r === "gateway" ? "denied-role" : r === "service" ? P(n) ? "denied-restricted" : "denied-other" : null;
78
+ }
79
+ function H(e) {
53
80
  if (!e) return "0 B";
54
81
  let t = [
55
82
  "B",
@@ -59,11 +86,11 @@ function L(e) {
59
86
  ], n = Math.min(Math.floor(Math.log(e) / Math.log(1024)), t.length - 1);
60
87
  return `${(e / 1024 ** n).toFixed(n === 0 ? 0 : 1)} ${t[n]}`;
61
88
  }
62
- function R(e) {
89
+ function U(e) {
63
90
  let t = e.lastIndexOf(".");
64
91
  return t >= 0 ? e.slice(t + 1).toLowerCase() : "bin";
65
92
  }
66
- function z(e) {
93
+ function W(e) {
67
94
  let t = new Date(e).getTime();
68
95
  if (Number.isNaN(t)) return "";
69
96
  let n = Date.now() - t, r = Math.round(n / 1e3);
@@ -75,8 +102,8 @@ function z(e) {
75
102
  let o = Math.round(a / 24);
76
103
  return o < 30 ? `${o} day${o === 1 ? "" : "s"} ago` : new Date(e).toLocaleDateString();
77
104
  }
78
- function B(e) {
79
- let t = (e.mimeType || "").toLowerCase(), n = (e.extension || R(e.name)).toLowerCase();
105
+ function G(e) {
106
+ let t = (e.mimeType || "").toLowerCase(), n = (e.extension || U(e.name)).toLowerCase();
80
107
  return t.startsWith("image/") || [
81
108
  "png",
82
109
  "jpg",
@@ -135,68 +162,68 @@ function B(e) {
135
162
  "pages"
136
163
  ].includes(n) ? "doc" : "other";
137
164
  }
138
- var V = {
165
+ var K = {
139
166
  image: {
140
- Icon: f,
167
+ Icon: h,
141
168
  iconClass: "text-status-success-text",
142
169
  tileClass: "bg-status-success-bg-subtle"
143
170
  },
144
171
  pdf: {
145
- Icon: u,
172
+ Icon: p,
146
173
  iconClass: "text-status-error-text",
147
174
  tileClass: "bg-status-error-bg-subtle"
148
175
  },
149
176
  sheet: {
150
- Icon: l,
177
+ Icon: f,
151
178
  iconClass: "text-status-success-text",
152
179
  tileClass: "bg-status-success-bg-subtle"
153
180
  },
154
181
  doc: {
155
- Icon: u,
182
+ Icon: p,
156
183
  iconClass: "text-status-info-text",
157
184
  tileClass: "bg-status-info-bg-subtle"
158
185
  },
159
186
  archive: {
160
- Icon: o,
187
+ Icon: l,
161
188
  iconClass: "text-status-warning-text",
162
189
  tileClass: "bg-status-warning-bg-subtle"
163
190
  },
164
191
  code: {
165
- Icon: c,
192
+ Icon: d,
166
193
  iconClass: "text-status-info-text",
167
194
  tileClass: "bg-status-info-bg-subtle"
168
195
  },
169
196
  video: {
170
- Icon: d,
197
+ Icon: m,
171
198
  iconClass: "text-status-info-text",
172
199
  tileClass: "bg-status-info-bg-subtle"
173
200
  },
174
201
  audio: {
175
- Icon: s,
202
+ Icon: u,
176
203
  iconClass: "text-status-warning-text",
177
204
  tileClass: "bg-status-warning-bg-subtle"
178
205
  },
179
206
  other: {
180
- Icon: a,
207
+ Icon: c,
181
208
  iconClass: "text-text-secondary",
182
209
  tileClass: "bg-bg-sunken"
183
210
  }
184
211
  };
185
- function H({ file: e }) {
186
- let t = B(e), { Icon: n, iconClass: r, tileClass: i } = V[t];
187
- return t === "image" && e.downloadUrl ? /* @__PURE__ */ x("img", {
212
+ function le({ file: e }) {
213
+ let t = G(e), { Icon: n, iconClass: r, tileClass: i } = K[t];
214
+ return t === "image" && e.downloadUrl ? /* @__PURE__ */ w("img", {
188
215
  src: e.downloadUrl,
189
216
  alt: "",
190
217
  loading: "lazy",
191
218
  className: "h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover"
192
- }) : /* @__PURE__ */ x("span", {
219
+ }) : /* @__PURE__ */ w("span", {
193
220
  className: `flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${i}`,
194
221
  "aria-hidden": "true",
195
- children: /* @__PURE__ */ x(n, { className: `h-5 w-5 ${r}` })
222
+ children: /* @__PURE__ */ w(n, { className: `h-5 w-5 ${r}` })
196
223
  });
197
224
  }
198
- function U({ entityType: a, entityId: o }) {
199
- let s = n(null), [c, l] = r(!1), [u, d] = r(null), { data: f, loading: v, refetch: B } = b(N, {
225
+ function q({ entityType: t, entityId: c }) {
226
+ let l = a(null), [u, d] = o(!1), [f, p] = o(null), [m, h] = o({}), x = te(), { data: I, loading: L, refetch: R } = C(ae, {
200
227
  variables: {
201
228
  pagination: {
202
229
  limit: 25,
@@ -209,14 +236,14 @@ function U({ entityType: a, entityId: o }) {
209
236
  },
210
237
  fetchPolicy: "cache-and-network",
211
238
  errorPolicy: "all"
212
- }), [V] = y(P), [U] = y(F), [W] = y(I), G = t(() => f?.getFiles.files ?? [], [f]), K = async (t) => {
239
+ }), [G] = S(oe), [K] = S(se), [q] = S(ce), J = i(() => I?.getFiles.files ?? [], [I]), ue = async (t) => {
213
240
  let n = t.target.files?.[0];
214
241
  if (t.target.value = "", n) {
215
- l(!0);
242
+ d(!0);
216
243
  try {
217
- let t = await V({ variables: { input: {
244
+ let t = await G({ variables: { input: {
218
245
  fileName: n.name,
219
- extension: R(n.name),
246
+ extension: U(n.name),
220
247
  fileSize: n.size,
221
248
  mimeType: n.type || "application/octet-stream",
222
249
  visibility: "WORKSPACE"
@@ -228,150 +255,201 @@ function U({ entityType: a, entityId: o }) {
228
255
  headers: { "Content-Type": n.type || "application/octet-stream" }
229
256
  });
230
257
  if (!a.ok) throw Error(`Storage rejected the upload (HTTP ${a.status}).`);
231
- await U({ variables: { input: {
258
+ await K({ variables: { input: {
232
259
  sessionId: i,
233
260
  fileSize: n.size
234
- } } }), e.success("File uploaded", `${n.name} is now available on this record.`), await B();
261
+ } } }), e.success("File uploaded", `${n.name} is now available on this record.`), await R();
235
262
  } catch (t) {
236
263
  e.error("Upload failed", t instanceof Error ? t.message : "The file could not be uploaded.");
237
264
  } finally {
238
- l(!1);
265
+ d(!1);
239
266
  }
240
267
  }
241
- }, q = async (t) => {
242
- d(t);
268
+ }, Y = r((e, t) => {
269
+ h((n) => {
270
+ let r = { ...n };
271
+ return t ? r[e] = t : delete r[e], r;
272
+ });
273
+ }, []), X = r(async (e) => {
274
+ Y(e, "checking");
275
+ try {
276
+ let t = (await x.query({
277
+ query: N,
278
+ variables: { fileId: e },
279
+ fetchPolicy: "network-only"
280
+ })).data?.getUserFilePermissions.canShare === !0 ? "allowed" : "denied-restricted";
281
+ return Y(e, t), t;
282
+ } catch (t) {
283
+ return n(t) ? (Y(e, "unverified"), "unverified") : (Y(e, null), "failed");
284
+ }
285
+ }, [x, Y]), de = (e, t) => {
286
+ t && (P(e.visibility) ? m[e.id] !== "checking" && X(e.id) : z(m[e.id]) && Y(e.id, null));
287
+ }, Z = async (t, { recheck: n }) => {
288
+ let r = t.id;
289
+ p(r);
243
290
  try {
244
- let n = (await W({ variables: { input: {
245
- resourceId: t,
291
+ if (P(t.visibility)) {
292
+ let t = m[r], i = !n && (t === "allowed" || t === "unverified") ? t : await X(r);
293
+ if (i === "failed") throw Error("Your share access could not be checked. Please try again.");
294
+ if (i === "denied-restricted") {
295
+ e.error(F, B(i));
296
+ return;
297
+ }
298
+ }
299
+ let i = (await q({ variables: { input: {
300
+ resourceId: r,
246
301
  role: "VIEWER"
247
302
  } } })).data?.createFileShareLink.shareUrl;
248
- if (!n) throw Error("No share link was returned.");
249
- navigator.clipboard?.writeText ? (await navigator.clipboard.writeText(n), e.success("Share link copied", "A view-only link is on your clipboard.")) : e.info("Share link ready", n);
250
- } catch (t) {
251
- e.error("Could not create share link", t instanceof Error ? t.message : "Please try again.");
303
+ if (!i) throw Error("No share link was returned.");
304
+ Y(r, "allowed"), navigator.clipboard?.writeText ? (await navigator.clipboard.writeText(i), e.success("Share link copied", "A view-only link is on your clipboard.")) : e.info("Share link ready", i);
305
+ } catch (n) {
306
+ let i = V(n, t.visibility);
307
+ if (i) {
308
+ Y(r, i), e.error(F, B(i));
309
+ return;
310
+ }
311
+ e.error("Could not create share link", n instanceof Error ? n.message : "Please try again.");
252
312
  } finally {
253
- d(null);
313
+ p(null);
254
314
  }
255
- }, J = f?.getFiles.total ?? 0, Y = G.length > 0;
256
- return /* @__PURE__ */ S(w, { children: [/* @__PURE__ */ x(E, {
315
+ }, Q = I?.getFiles.total ?? 0, $ = J.length > 0;
316
+ return /* @__PURE__ */ T(E, { children: [/* @__PURE__ */ w(O, {
257
317
  className: "flex flex-row items-center justify-between gap-2 space-y-0",
258
- children: /* @__PURE__ */ S(D, {
318
+ children: /* @__PURE__ */ T(k, {
259
319
  className: "flex items-center gap-2 text-base",
260
320
  children: [
261
- /* @__PURE__ */ x(g, { className: "h-4 w-4" }),
321
+ /* @__PURE__ */ w(b, { className: "h-4 w-4" }),
262
322
  "Attachments",
263
- J ? /* @__PURE__ */ S("span", {
323
+ Q ? /* @__PURE__ */ T("span", {
264
324
  className: "text-sm font-normal text-text-secondary",
265
325
  children: [
266
- J,
326
+ Q,
267
327
  " file",
268
- J === 1 ? "" : "s"
328
+ Q === 1 ? "" : "s"
269
329
  ]
270
330
  }) : null
271
331
  ]
272
332
  })
273
- }), /* @__PURE__ */ S(T, {
333
+ }), /* @__PURE__ */ T(D, {
274
334
  className: "space-y-3",
275
335
  children: [
276
- /* @__PURE__ */ x("input", {
277
- ref: s,
336
+ /* @__PURE__ */ w("input", {
337
+ ref: l,
278
338
  type: "file",
279
339
  className: "hidden",
280
- onChange: (e) => void K(e),
281
- "aria-label": `Upload a file for ${a} ${o}`
340
+ onChange: (e) => void ue(e),
341
+ "aria-label": `Upload a file for ${t} ${c}`
282
342
  }),
283
- v && !Y ? /* @__PURE__ */ x("div", {
343
+ L && !$ ? /* @__PURE__ */ w("div", {
284
344
  className: "space-y-2",
285
- children: Array.from({ length: 3 }).map((e, t) => /* @__PURE__ */ x("div", { className: "h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer" }, t))
286
- }) : Y ? /* @__PURE__ */ x("ul", {
345
+ children: Array.from({ length: 3 }).map((e, t) => /* @__PURE__ */ w("div", { className: "h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer" }, t))
346
+ }) : $ ? /* @__PURE__ */ w("ul", {
287
347
  className: "space-y-1.5",
288
- children: G.map((e) => {
289
- let t = u === e.id;
290
- return /* @__PURE__ */ S("li", {
348
+ children: J.map((e) => {
349
+ let t = f === e.id, n = m[e.id], r = n === "checking", i = z(n) ? n : null, a = i !== null;
350
+ return /* @__PURE__ */ T("li", {
291
351
  className: "flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5",
292
352
  children: [
293
- /* @__PURE__ */ x(H, { file: e }),
294
- /* @__PURE__ */ S("div", {
353
+ /* @__PURE__ */ w(le, { file: e }),
354
+ /* @__PURE__ */ T("div", {
295
355
  className: "min-w-0 flex-1",
296
- children: [/* @__PURE__ */ x("p", {
356
+ children: [/* @__PURE__ */ w("p", {
297
357
  className: "truncate text-sm font-medium",
298
358
  title: e.name,
299
359
  children: e.name
300
- }), /* @__PURE__ */ S("p", {
360
+ }), /* @__PURE__ */ T("p", {
301
361
  className: "truncate text-xs text-text-secondary",
302
362
  children: [
303
- L(Number(e.size)),
363
+ H(Number(e.size)),
304
364
  " · ",
305
- z(e.createdAt)
365
+ W(e.createdAt)
306
366
  ]
307
367
  })]
308
368
  }),
309
- /* @__PURE__ */ S("div", {
369
+ /* @__PURE__ */ T("div", {
310
370
  className: "hidden items-center gap-1 sm:flex",
311
- children: [e.downloadUrl ? /* @__PURE__ */ x("a", {
371
+ children: [e.downloadUrl ? /* @__PURE__ */ w("a", {
312
372
  href: e.downloadUrl,
313
373
  target: "_blank",
314
374
  rel: "noreferrer",
315
375
  className: "rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary",
316
376
  "aria-label": `Download ${e.name}`,
317
377
  title: "Download",
318
- children: /* @__PURE__ */ x(i, { className: "h-4 w-4" })
319
- }) : null, /* @__PURE__ */ x("button", {
378
+ children: /* @__PURE__ */ w(s, { className: "h-4 w-4" })
379
+ }) : null, /* @__PURE__ */ w("button", {
320
380
  type: "button",
321
- disabled: t,
322
- onClick: () => void q(e.id),
323
- className: "rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary disabled:opacity-60",
381
+ disabled: t || r,
382
+ "aria-disabled": a ? !0 : void 0,
383
+ "data-share-state": a ? "denied" : void 0,
384
+ onClick: () => void Z(e, { recheck: !0 }),
385
+ className: `rounded-md p-1.5 transition-colors disabled:opacity-60 ${a ? "cursor-not-allowed text-text-tertiary opacity-60" : "text-text-secondary hover:bg-bg-sunken hover:text-text-primary"}`,
324
386
  "aria-label": `Copy share link for ${e.name}`,
325
- title: "Copy share link",
326
- children: t ? /* @__PURE__ */ x(m, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ x(p, { className: "h-4 w-4" })
387
+ title: i ? `${F}. ${B(i)}` : "Copy share link",
388
+ children: t || r ? /* @__PURE__ */ w(_, { className: "h-4 w-4 animate-spin" }) : a ? /* @__PURE__ */ w(v, {
389
+ className: "h-4 w-4",
390
+ "aria-hidden": "true"
391
+ }) : /* @__PURE__ */ w(g, { className: "h-4 w-4" })
327
392
  })]
328
393
  }),
329
- /* @__PURE__ */ x("div", {
394
+ /* @__PURE__ */ w("div", {
330
395
  className: "sm:hidden",
331
- children: /* @__PURE__ */ S(O, { children: [/* @__PURE__ */ x(j, {
332
- asChild: !0,
333
- children: /* @__PURE__ */ x("button", {
334
- type: "button",
335
- className: "rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary",
336
- "aria-label": `Actions for ${e.name}`,
337
- children: /* @__PURE__ */ x(h, { className: "h-4 w-4" })
338
- })
339
- }), /* @__PURE__ */ S(k, {
340
- align: "end",
341
- children: [e.downloadUrl ? /* @__PURE__ */ x(A, {
396
+ children: /* @__PURE__ */ T(A, {
397
+ onOpenChange: (t) => de(e, t),
398
+ children: [/* @__PURE__ */ w(re, {
342
399
  asChild: !0,
343
- children: /* @__PURE__ */ S("a", {
344
- href: e.downloadUrl,
345
- target: "_blank",
346
- rel: "noreferrer",
347
- children: [/* @__PURE__ */ x(i, { className: "mr-2 h-4 w-4" }), "Download"]
400
+ children: /* @__PURE__ */ w("button", {
401
+ type: "button",
402
+ className: "rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary",
403
+ "aria-label": `Actions for ${e.name}`,
404
+ children: /* @__PURE__ */ w(y, { className: "h-4 w-4" })
348
405
  })
349
- }) : null, /* @__PURE__ */ S(A, {
350
- disabled: t,
351
- onSelect: (t) => {
352
- t.preventDefault(), q(e.id);
353
- },
354
- children: [/* @__PURE__ */ x(p, { className: "mr-2 h-4 w-4" }), "Copy share link"]
406
+ }), /* @__PURE__ */ T(j, {
407
+ align: "end",
408
+ children: [
409
+ e.downloadUrl ? /* @__PURE__ */ w(M, {
410
+ asChild: !0,
411
+ children: /* @__PURE__ */ T("a", {
412
+ href: e.downloadUrl,
413
+ target: "_blank",
414
+ rel: "noreferrer",
415
+ children: [/* @__PURE__ */ w(s, { className: "mr-2 h-4 w-4" }), "Download"]
416
+ })
417
+ }) : null,
418
+ /* @__PURE__ */ T(M, {
419
+ disabled: t || r || a,
420
+ onSelect: (t) => {
421
+ t.preventDefault(), Z(e, { recheck: !1 });
422
+ },
423
+ children: [r ? /* @__PURE__ */ w(_, { className: "mr-2 h-4 w-4 animate-spin" }) : a ? /* @__PURE__ */ w(v, {
424
+ className: "mr-2 h-4 w-4",
425
+ "aria-hidden": "true"
426
+ }) : /* @__PURE__ */ w(g, { className: "mr-2 h-4 w-4" }), r ? "Checking share access…" : a ? "No share access" : "Copy share link"]
427
+ }),
428
+ i ? /* @__PURE__ */ w("p", {
429
+ className: "max-w-56 px-2 py-1.5 text-xs text-text-secondary",
430
+ children: B(i)
431
+ }) : null
432
+ ]
355
433
  })]
356
- })] })
434
+ })
357
435
  })
358
436
  ]
359
437
  }, e.id);
360
438
  })
361
- }) : /* @__PURE__ */ x(M, {
439
+ }) : /* @__PURE__ */ w(ie, {
362
440
  illustration: "empty-data",
363
441
  title: "No files yet",
364
442
  description: "Upload photos, receipts, warranties or documents to keep them with this record."
365
443
  }),
366
- /* @__PURE__ */ S(C, {
444
+ /* @__PURE__ */ T(ne, {
367
445
  className: "w-full",
368
- disabled: c,
369
- onClick: () => s.current?.click(),
370
- children: [c ? /* @__PURE__ */ x(m, { className: "mr-2 h-4 w-4 animate-spin" }) : /* @__PURE__ */ x(_, { className: "mr-2 h-4 w-4" }), c ? "Uploading…" : "Upload files"]
446
+ disabled: u,
447
+ onClick: () => l.current?.click(),
448
+ children: [u ? /* @__PURE__ */ w(_, { className: "mr-2 h-4 w-4 animate-spin" }) : /* @__PURE__ */ w(ee, { className: "mr-2 h-4 w-4" }), u ? "Uploading…" : "Upload files"]
371
449
  })
372
450
  ]
373
451
  })] });
374
452
  }
375
- export { U as AttachmentsPanel };
453
+ export { q as AttachmentsPanel };
376
454
 
377
455
  //# sourceMappingURL=AttachmentsPanel.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"AttachmentsPanel.js","names":["ImageIcon","FileIcon","GET_FILES_QUERY","INITIATE_UPLOAD_MUTATION","COMPLETE_UPLOAD_MUTATION","CREATE_SHARE_LINK_MUTATION","formatSize","bytes","units","exponent","extensionOf","dot","fileName","relativeTime","then","iso","diff","sec","min","hr","day","kindOf","mime","file","ext","KIND_STYLE","Image","FileText","FileSpreadsheet","FileArchive","FileCode2","FileVideo","FileAudio","File","FileBadge","kind","tileClass","Icon","iconClass","AttachmentsPanel","fileInputRef","useRef","uploading","setUploading","useState","sharingId","setSharingId","useQuery","initiateUpload","useMutation","completeUpload","createShareLink","files","useMemo","data","handleFileSelected","event","initiated","uploadUrl","sessionId","putResponse","notify","refetch","error","handleShare","fileId","url","total","hasFiles","Card","CardHeader","CardTitle","Paperclip","CardContent","entityType","entityId","loading","i","isSharing","Download","Loader2","Link2","DropdownMenu","DropdownMenuTrigger","MoreHorizontal","DropdownMenuContent","DropdownMenuItem","e","IllustratedEmptyState","Button","Upload"],"sources":["../../../src/assethandler/components/AttachmentsPanel.tsx"],"sourcesContent":["import { useMemo, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@burdenoff/fe-libs/ui';\nimport {\n Paperclip,\n Upload,\n Link2,\n FileText,\n FileSpreadsheet,\n FileArchive,\n FileCode2,\n FileVideo,\n FileAudio,\n Image as ImageIcon,\n File as FileIcon,\n Loader2,\n Download,\n MoreHorizontal,\n} from 'lucide-react';\nimport { notify } from '../utils/toast';\n\n/**\n * Attach and reference files for an AssetHandler entity, backed by the\n * federated wspace-files-svc (same wspace gateway/Apollo client). Fully real (no fabricated data) —\n * lists workspace files via `getFiles`, uploads new files through the\n * `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public\n * share links via `createFileShareLink`. All entries are live data.\n *\n * `entityType`/`entityId` scope the panel to the record it is mounted on and\n * are used for context labelling; uploaded files land in the workspace file\n * store so they can be shared and reused.\n */\nexport interface AttachmentsPanelProps {\n entityType: string;\n entityId: string;\n}\n\ninterface FileNode {\n id: string;\n name: string;\n extension: string;\n mimeType: string;\n size: number;\n downloadUrl?: string | null;\n createdAt: string;\n}\n\ninterface GetFilesResult {\n getFiles: {\n files: FileNode[];\n total: number;\n hasMore: boolean;\n };\n}\n\nconst GET_FILES_QUERY = gql`\n query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {\n getFiles(pagination: $pagination, sort: $sort) {\n total\n hasMore\n files {\n id\n name\n extension\n mimeType\n size\n downloadUrl\n createdAt\n }\n }\n }\n`;\n\nconst INITIATE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session {\n id\n }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file {\n id\n name\n }\n }\n }\n`;\n\nconst CREATE_SHARE_LINK_MUTATION = gql`\n mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {\n createFileShareLink(input: $input) {\n id\n shareUrl\n }\n }\n`;\n\nfunction formatSize(bytes: number): string {\n if (!bytes) return '0 B';\n const units = ['B', 'KB', 'MB', 'GB'];\n const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);\n const value = bytes / Math.pow(1024, exponent);\n return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n}\n\nfunction extensionOf(fileName: string): string {\n const dot = fileName.lastIndexOf('.');\n return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : 'bin';\n}\n\n/** Compact relative time (\"2 hrs ago\"), with the full timestamp on hover. */\nfunction relativeTime(iso: string): string {\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diff = Date.now() - then;\n const sec = Math.round(diff / 1000);\n if (sec < 45) return 'just now';\n const min = Math.round(sec / 60);\n if (min < 60) return `${min} min ago`;\n const hr = Math.round(min / 60);\n if (hr < 24) return `${hr} hr${hr === 1 ? '' : 's'} ago`;\n const day = Math.round(hr / 24);\n if (day < 30) return `${day} day${day === 1 ? '' : 's'} ago`;\n return new Date(iso).toLocaleDateString();\n}\n\ntype FileKind =\n 'image' | 'pdf' | 'sheet' | 'doc' | 'archive' | 'code' | 'video' | 'audio' | 'other';\n\n/** Map a file's extension/mime to a recognisable kind for icon + tint. */\nfunction kindOf(file: FileNode): FileKind {\n const mime = (file.mimeType || '').toLowerCase();\n const ext = (file.extension || extensionOf(file.name)).toLowerCase();\n if (\n mime.startsWith('image/') ||\n ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp'].includes(ext)\n )\n return 'image';\n if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext))\n return 'video';\n if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext))\n return 'audio';\n if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';\n if (['xls', 'xlsx', 'csv', 'tsv', 'ods'].includes(ext)) return 'sheet';\n if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) return 'archive';\n if (\n [\n 'js',\n 'ts',\n 'tsx',\n 'jsx',\n 'json',\n 'xml',\n 'yml',\n 'yaml',\n 'html',\n 'css',\n 'sh',\n 'py',\n 'sql',\n ].includes(ext)\n )\n return 'code';\n if (['doc', 'docx', 'txt', 'md', 'rtf', 'odt', 'pages'].includes(ext)) return 'doc';\n return 'other';\n}\n\nconst KIND_STYLE: Record<\n FileKind,\n { Icon: typeof FileText; iconClass: string; tileClass: string }\n> = {\n image: {\n Icon: ImageIcon,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n pdf: {\n Icon: FileText,\n iconClass: 'text-status-error-text',\n tileClass: 'bg-status-error-bg-subtle',\n },\n sheet: {\n Icon: FileSpreadsheet,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n doc: {\n Icon: FileText,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n archive: {\n Icon: FileArchive,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n code: {\n Icon: FileCode2,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n video: {\n Icon: FileVideo,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n audio: {\n Icon: FileAudio,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n other: { Icon: FileIcon, iconClass: 'text-text-secondary', tileClass: 'bg-bg-sunken' },\n};\n\n/** File-type badge tile — an inline thumbnail for images, a tinted glyph otherwise. */\nfunction FileBadge({ file }: { file: FileNode }) {\n const kind = kindOf(file);\n const { Icon, iconClass, tileClass } = KIND_STYLE[kind];\n if (kind === 'image' && file.downloadUrl) {\n return (\n <img\n src={file.downloadUrl}\n alt=\"\"\n loading=\"lazy\"\n className=\"h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover\"\n />\n );\n }\n return (\n <span\n className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${tileClass}`}\n aria-hidden=\"true\"\n >\n <Icon className={`h-5 w-5 ${iconClass}`} />\n </span>\n );\n}\n\nexport function AttachmentsPanel({ entityType, entityId }: AttachmentsPanelProps) {\n const fileInputRef = useRef<HTMLInputElement>(null);\n const [uploading, setUploading] = useState(false);\n const [sharingId, setSharingId] = useState<string | null>(null);\n\n const { data, loading, refetch } = useQuery<GetFilesResult>(GET_FILES_QUERY, {\n variables: {\n pagination: { limit: 25, offset: 0 },\n sort: { field: 'CREATED_AT', order: 'DESC' },\n },\n fetchPolicy: 'cache-and-network',\n errorPolicy: 'all',\n });\n\n const [initiateUpload] = useMutation<{\n initiateUpload: { uploadUrl?: string | null; session: { id: string } };\n }>(INITIATE_UPLOAD_MUTATION);\n const [completeUpload] = useMutation(COMPLETE_UPLOAD_MUTATION);\n const [createShareLink] = useMutation<{\n createFileShareLink: { id: string; shareUrl?: string | null };\n }>(CREATE_SHARE_LINK_MUTATION);\n\n const files = useMemo(() => data?.getFiles.files ?? [], [data]);\n\n const handleFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n // Reset the input so selecting the same file again re-triggers change.\n event.target.value = '';\n if (!file) return;\n\n setUploading(true);\n try {\n const initiated = await initiateUpload({\n variables: {\n input: {\n fileName: file.name,\n extension: extensionOf(file.name),\n fileSize: file.size,\n mimeType: file.type || 'application/octet-stream',\n visibility: 'WORKSPACE',\n },\n },\n });\n\n const uploadUrl = initiated.data?.initiateUpload.uploadUrl;\n const sessionId = initiated.data?.initiateUpload.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('Upload could not be initialised.');\n }\n\n const putResponse = await fetch(uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': file.type || 'application/octet-stream' },\n });\n if (!putResponse.ok) {\n throw new Error(`Storage rejected the upload (HTTP ${putResponse.status}).`);\n }\n\n await completeUpload({\n variables: { input: { sessionId, fileSize: file.size } },\n });\n\n notify.success('File uploaded', `${file.name} is now available on this record.`);\n await refetch();\n } catch (error) {\n notify.error(\n 'Upload failed',\n error instanceof Error ? error.message : 'The file could not be uploaded.'\n );\n } finally {\n setUploading(false);\n }\n };\n\n const handleShare = async (fileId: string) => {\n setSharingId(fileId);\n try {\n const result = await createShareLink({\n variables: { input: { resourceId: fileId, role: 'VIEWER' } },\n });\n const url = result.data?.createFileShareLink.shareUrl;\n if (!url) {\n throw new Error('No share link was returned.');\n }\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(url);\n notify.success('Share link copied', 'A view-only link is on your clipboard.');\n } else {\n notify.info('Share link ready', url);\n }\n } catch (error) {\n notify.error(\n 'Could not create share link',\n error instanceof Error ? error.message : 'Please try again.'\n );\n } finally {\n setSharingId(null);\n }\n };\n\n const total = data?.getFiles.total ?? 0;\n const hasFiles = files.length > 0;\n\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between gap-2 space-y-0\">\n <CardTitle className=\"flex items-center gap-2 text-base\">\n <Paperclip className=\"h-4 w-4\" />\n Attachments\n {total ? (\n <span className=\"text-sm font-normal text-text-secondary\">\n {total} file{total === 1 ? '' : 's'}\n </span>\n ) : null}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(event) => void handleFileSelected(event)}\n aria-label={`Upload a file for ${entityType} ${entityId}`}\n />\n\n {loading && !hasFiles ? (\n <div className=\"space-y-2\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div\n key={i}\n className=\"h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer\"\n />\n ))}\n </div>\n ) : !hasFiles ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No files yet\"\n description=\"Upload photos, receipts, warranties or documents to keep them with this record.\"\n />\n ) : (\n <ul className=\"space-y-1.5\">\n {files.map((file) => {\n const isSharing = sharingId === file.id;\n return (\n <li\n key={file.id}\n className=\"flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5\"\n >\n <FileBadge file={file} />\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\" title={file.name}>\n {file.name}\n </p>\n <p className=\"truncate text-xs text-text-secondary\">\n {formatSize(Number(file.size))} · {relativeTime(file.createdAt)}\n </p>\n </div>\n {/* Desktop: inline actions. Mobile: kebab menu to avoid crowding. */}\n <div className=\"hidden items-center gap-1 sm:flex\">\n {file.downloadUrl ? (\n <a\n href={file.downloadUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Download ${file.name}`}\n title=\"Download\"\n >\n <Download className=\"h-4 w-4\" />\n </a>\n ) : null}\n <button\n type=\"button\"\n disabled={isSharing}\n onClick={() => void handleShare(file.id)}\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary disabled:opacity-60\"\n aria-label={`Copy share link for ${file.name}`}\n title=\"Copy share link\"\n >\n {isSharing ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : (\n <Link2 className=\"h-4 w-4\" />\n )}\n </button>\n </div>\n <div className=\"sm:hidden\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n className=\"rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Actions for ${file.name}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {file.downloadUrl ? (\n <DropdownMenuItem asChild>\n <a href={file.downloadUrl} target=\"_blank\" rel=\"noreferrer\">\n <Download className=\"mr-2 h-4 w-4\" />\n Download\n </a>\n </DropdownMenuItem>\n ) : null}\n <DropdownMenuItem\n disabled={isSharing}\n onSelect={(e) => {\n e.preventDefault();\n void handleShare(file.id);\n }}\n >\n <Link2 className=\"mr-2 h-4 w-4\" />\n Copy share link\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Prominent full-width primary upload CTA (matches the reference concept). */}\n <Button\n className=\"w-full\"\n disabled={uploading}\n onClick={() => fileInputRef.current?.click()}\n >\n {uploading ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Upload className=\"mr-2 h-4 w-4\" />\n )}\n {uploading ? 'Uploading…' : 'Upload files'}\n </Button>\n </CardContent>\n </Card>\n );\n}\n"],"mappings":";;;;;;;;AAmEA,IAAME,IAAkB,CAAG;;;;;;;;;;;;;;;;GAkBrBC,IAA2B,CAAG;;;;;;;;;GAW9BC,IAA2B,CAAG;;;;;;;;;;GAY9BC,IAA6B,CAAG;;;;;;;;AAStC,SAASC,EAAW,GAAuB;CACzC,IAAI,CAACC,GAAO,OAAO;CACnB,IAAMC,IAAQ;EAAC;EAAK;EAAM;EAAM;CAAI,GAC9BC,IAAW,KAAK,IAAI,KAAK,MAAM,KAAK,IAAIF,CAAK,IAAI,KAAK,IAAI,IAAI,CAAC,GAAGC,EAAM,SAAS,CAAC;CAExF,OAAO,IADOD,IAAiB,QAAME,EAAQ,CAC7B,QAAQA,MAAa,IAAI,IAAI,CAAC,EAAE,GAAGD,EAAMC;AAC3D;AAEA,SAASC,EAAY,GAA0B;CAC7C,IAAMC,IAAMC,EAAS,YAAY,GAAG;CACpC,OAAOD,KAAO,IAAIC,EAAS,MAAMD,IAAM,CAAC,CAAC,CAAC,YAAY,IAAI;AAC5D;AAGA,SAASE,EAAa,GAAqB;CACzC,IAAMC,IAAO,IAAI,KAAKC,CAAG,CAAC,CAAC,QAAQ;CACnC,IAAI,OAAO,MAAMD,CAAI,GAAG,OAAO;CAC/B,IAAME,IAAO,KAAK,IAAI,IAAIF,GACpBG,IAAM,KAAK,MAAMD,IAAO,GAAI;CAClC,IAAIC,IAAM,IAAI,OAAO;CACrB,IAAMC,IAAM,KAAK,MAAMD,IAAM,EAAE;CAC/B,IAAIC,IAAM,IAAI,OAAO,GAAGA,EAAI;CAC5B,IAAMC,IAAK,KAAK,MAAMD,IAAM,EAAE;CAC9B,IAAIC,IAAK,IAAI,OAAO,GAAGA,EAAG,KAAKA,MAAO,IAAI,KAAK,IAAI;CACnD,IAAMC,IAAM,KAAK,MAAMD,IAAK,EAAE;CAE9B,OADIC,IAAM,KAAW,GAAGA,EAAI,MAAMA,MAAQ,IAAI,KAAK,IAAI,QAChD,IAAI,KAAKL,CAAG,CAAC,CAAC,mBAAmB;AAC1C;AAMA,SAASM,EAAO,GAA0B;CACxC,IAAMC,KAAQC,EAAK,YAAY,GAAA,CAAI,YAAY,GACzCC,KAAOD,EAAK,aAAab,EAAYa,EAAK,IAAI,EAAA,CAAG,YAAY;CAgCnE,OA9BED,EAAK,WAAW,QAAQ,KACxB;EAAC;EAAO;EAAO;EAAQ;EAAO;EAAQ;EAAO;EAAQ;CAAK,CAAC,CAAC,SAASE,CAAG,IAEjE,UACLF,EAAK,WAAW,QAAQ,KAAK;EAAC;EAAO;EAAO;EAAQ;EAAO;CAAK,CAAC,CAAC,SAASE,CAAG,IACzE,UACLF,EAAK,WAAW,QAAQ,KAAK;EAAC;EAAO;EAAO;EAAO;EAAO;CAAM,CAAC,CAAC,SAASE,CAAG,IACzE,UACLF,MAAS,qBAAqBE,MAAQ,QAAc,QACpD;EAAC;EAAO;EAAQ;EAAO;EAAO;CAAK,CAAC,CAAC,SAASA,CAAG,IAAU,UAC3D;EAAC;EAAO;EAAO;EAAM;EAAO;EAAM;CAAK,CAAC,CAAC,SAASA,CAAG,IAAU,YAEjE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CACF,CAAC,CAAC,SAASA,CAAG,IAEP,SACL;EAAC;EAAO;EAAQ;EAAO;EAAM;EAAO;EAAO;CAAO,CAAC,CAAC,SAASA,CAAG,IAAU,QACvE;AACT;AAEA,IAAMC,IAGF;CACF,OAAO;EACL,MAAMzB;EACN,WAAW;EACX,WAAW;CACb;CACA,KAAK;EACH,MAAM2B;EACN,WAAW;EACX,WAAW;CACb;CACA,OAAO;EACL,MAAMC;EACN,WAAW;EACX,WAAW;CACb;CACA,KAAK;EACH,MAAMD;EACN,WAAW;EACX,WAAW;CACb;CACA,SAAS;EACP,MAAME;EACN,WAAW;EACX,WAAW;CACb;CACA,MAAM;EACJ,MAAMC;EACN,WAAW;EACX,WAAW;CACb;CACA,OAAO;EACL,MAAMC;EACN,WAAW;EACX,WAAW;CACb;CACA,OAAO;EACL,MAAMC;EACN,WAAW;EACX,WAAW;CACb;CACA,OAAO;EAAE,MAAM/B;EAAU,WAAW;EAAuB,WAAW;CAAe;AACvF;AAGA,SAASiC,EAAU,EAAE,WAA4B;CAC/C,IAAMC,IAAOd,EAAOE,CAAI,GAClB,EAAE,SAAM,cAAW,iBAAcE,EAAWU;CAWlD,OAVIA,MAAS,WAAWZ,EAAK,cAEzB,kBAAC,OAAD;EACE,KAAKA,EAAK;EACV,KAAI;EACJ,SAAQ;EACR,WAAU;CACX,CAAA,IAIH,kBAAC,QAAD;EACE,WAAW,kEAAkEa;EAC7E,eAAY;EAEZ,UAAA,kBAACC,GAAD,EAAM,WAAW,WAAWC,IAAc,CAAA;CACtC,CAAA;AAEV;AAEA,SAAgBC,EAAiB,EAAE,eAAY,eAAmC;CAChF,IAAMC,IAAeC,EAAyB,IAAI,GAC5C,CAACC,GAAWC,KAAgBC,EAAS,EAAK,GAC1C,CAACC,GAAWC,KAAgBF,EAAwB,IAAI,GAExD,EAAE,SAAM,YAAS,eAAYG,EAAyB7C,GAAiB;EAC3E,WAAW;GACT,YAAY;IAAE,OAAO;IAAI,QAAQ;GAAE;GACnC,MAAM;IAAE,OAAO;IAAc,OAAO;GAAO;EAC7C;EACA,aAAa;EACb,aAAa;CACf,CAAC,GAEK,CAAC8C,KAAkBC,EAEtB9C,CAAwB,GACrB,CAAC+C,KAAkBD,EAAY7C,CAAwB,GACvD,CAAC+C,KAAmBF,EAEvB5C,CAA0B,GAEvB+C,IAAQC,QAAcC,GAAM,SAAS,SAAS,CAAC,GAAG,CAACA,CAAI,CAAC,GAExDC,IAAqB,OAAO,MAA+C;EAC/E,IAAMhC,IAAOiC,EAAM,OAAO,QAAQ;EAGlC,IADA,EAAM,OAAO,QAAQ,IAChBjC,GAEL;KAAa,EAAI;GACjB,IAAI;IACF,IAAMkC,IAAY,MAAMT,EAAe,EACrC,WAAW,EACT,OAAO;KACL,UAAUzB,EAAK;KACf,WAAWb,EAAYa,EAAK,IAAI;KAChC,UAAUA,EAAK;KACf,UAAUA,EAAK,QAAQ;KACvB,YAAY;IACd,EACF,EACF,CAAC,GAEKmC,IAAYD,EAAU,MAAM,eAAe,WAC3CE,IAAYF,EAAU,MAAM,eAAe,QAAQ;IACzD,IAAI,CAACC,KAAa,CAACC,GACjB,MAAU,MAAM,kCAAkC;IAGpD,IAAMC,IAAc,MAAM,MAAMF,GAAW;KACzC,QAAQ;KACR,MAAMnC;KACN,SAAS,EAAE,gBAAgBA,EAAK,QAAQ,2BAA2B;IACrE,CAAC;IACD,IAAI,CAACqC,EAAY,IACf,MAAU,MAAM,qCAAqCA,EAAY,OAAO,GAAG;IAQ7E,AALA,MAAMV,EAAe,EACnB,WAAW,EAAE,OAAO;KAAE;KAAW,UAAU3B,EAAK;IAAK,EAAE,EACzD,CAAC,GAEDsC,EAAO,QAAQ,iBAAiB,GAAGtC,EAAK,KAAK,kCAAkC,GAC/E,MAAMuC,EAAQ;GAChB,SAASC,GAAO;IACd,EAAO,MACL,iBACAA,aAAiB,QAAQA,EAAM,UAAU,iCAC3C;GACF,UAAU;IACR,EAAa,EAAK;GACpB;EA1CiB;CA2CnB,GAEMC,IAAc,OAAO,MAAmB;EAC5C,EAAaC,CAAM;EACnB,IAAI;GAIF,IAAMC,KAAM,MAHSf,EAAgB,EACnC,WAAW,EAAE,OAAO;IAAE,YAAYc;IAAQ,MAAM;GAAS,EAAE,EAC7D,CAAC,EAAA,CACkB,MAAM,oBAAoB;GAC7C,IAAI,CAACC,GACH,MAAU,MAAM,6BAA6B;GAE/C,AAAI,UAAU,WAAW,aACvB,MAAM,UAAU,UAAU,UAAUA,CAAG,GACvCL,EAAO,QAAQ,qBAAqB,wCAAwC,KAE5EA,EAAO,KAAK,oBAAoBK,CAAG;EAEvC,SAASH,GAAO;GACd,EAAO,MACL,+BACAA,aAAiB,QAAQA,EAAM,UAAU,mBAC3C;EACF,UAAU;GACR,EAAa,IAAI;EACnB;CACF,GAEMI,IAAQb,GAAM,SAAS,SAAS,GAChCc,IAAWhB,EAAM,SAAS;CAEhC,OACE,kBAACiB,GAAD,EAAA,UAAA,CACE,kBAACC,GAAD;EAAY,WAAU;EACpB,UAAA,kBAACC,GAAD;GAAW,WAAU;GAArB,UAAA;IACE,kBAACC,GAAD,EAAW,WAAU,UAAW,CAAA;IAAC;IAEhCL,IACC,kBAAC,QAAD;KAAM,WAAU;KAAhB,UAAA;MACGA;MAAM;MAAMA,MAAU,IAAI,KAAK;KAC5B;IACJ,CAAA,IAAA;GACK;;CACD,CAAA,GACZ,kBAACM,GAAD;EAAa,WAAU;EAAvB,UAAA;GACE,kBAAC,SAAD;IACE,KAAKjC;IACL,MAAK;IACL,WAAU;IACV,WAAW,MAAU,KAAKe,EAAmBC,CAAK;IAClD,cAAY,qBAAqBkB,EAAW,GAAGC;GAChD,CAAA;GAEAC,KAAW,CAACR,IACX,kBAAC,OAAD;IAAK,WAAU;IACZ,UAAA,MAAM,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,MACjC,kBAAC,OAAD,EAEE,WAAU,8DACX,GAFMS,CAEN,CACF;GACE,CAAA,IACFT,IAOH,kBAAC,MAAD;IAAI,WAAU;IACX,UAAAhB,EAAM,KAAK,MAAS;KACnB,IAAM0B,IAAYjC,MAActB,EAAK;KACrC,OACE,kBAAC,MAAD;MAEE,WAAU;MAFZ,UAAA;OAIE,kBAACW,GAAD,EAAiB,QAAO,CAAA;OACxB,kBAAC,OAAD;QAAK,WAAU;QAAf,UAAA,CACE,kBAAC,KAAD;SAAG,WAAU;SAA+B,OAAOX,EAAK;SACrD,UAAAA,EAAK;QACL,CAAA,GACH,kBAAC,KAAD;SAAG,WAAU;SAAb,UAAA;UACGjB,EAAW,OAAOiB,EAAK,IAAI,CAAC;UAAE;UAAIV,EAAaU,EAAK,SAAS;SAC7D;QACA,CAAA,CAAA;;OAEL,kBAAC,OAAD;QAAK,WAAU;QAAf,UAAA,CACGA,EAAK,cACJ,kBAAC,KAAD;SACE,MAAMA,EAAK;SACX,QAAO;SACP,KAAI;SACJ,WAAU;SACV,cAAY,YAAYA,EAAK;SAC7B,OAAM;SAEN,UAAA,kBAACwD,GAAD,EAAU,WAAU,UAAW,CAAA;QAC9B,CAAA,IACD,MACJ,kBAAC,UAAD;SACE,MAAK;SACL,UAAUD;SACV,eAAe,KAAKd,EAAYzC,EAAK,EAAE;SACvC,WAAU;SACV,cAAY,uBAAuBA,EAAK;SACxC,OAAM;SAEL,UAAAuD,IACC,kBAACE,GAAD,EAAS,WAAU,uBAAwB,CAAA,IAE3C,kBAACC,GAAD,EAAO,WAAU,UAAW,CAAA;QAExB,CAAA,CACL;;OACL,kBAAC,OAAD;QAAK,WAAU;QACb,UAAA,kBAACC,GAAD,EAAA,UAAA,CACE,kBAACC,GAAD;SAAqB,SAAA;SACnB,UAAA,kBAAC,UAAD;UACE,MAAK;UACL,WAAU;UACV,cAAY,eAAe5D,EAAK;UAEhC,UAAA,kBAAC6D,GAAD,EAAgB,WAAU,UAAW,CAAA;SAC/B,CAAA;QACW,CAAA,GACrB,kBAACC,GAAD;SAAqB,OAAM;SAA3B,UAAA,CACG9D,EAAK,cACJ,kBAAC+D,GAAD;UAAkB,SAAA;UAChB,UAAA,kBAAC,KAAD;WAAG,MAAM/D,EAAK;WAAa,QAAO;WAAS,KAAI;WAA/C,UAAA,CACE,kBAACwD,GAAD,EAAU,WAAU,eAAgB,CAAA,GAAC,UAEpC;;SACa,CAAA,IAChB,MACJ,kBAACO,GAAD;UACE,UAAUR;UACV,WAAW,MAAM;WAEf,AADAS,EAAE,eAAe,GACjBvB,EAAiBzC,EAAK,EAAE;UAC1B;UALF,UAAA,CAOE,kBAAC0D,GAAD,EAAO,WAAU,eAAgB,CAAA,GAAC,iBAElB;SACC,CAAA,CAAA;QACT,CAAA,CAAA,EAAA,CAAA;OACX,CAAA;MACH;KA1EG,GAAA1D,EAAK,EA0ER;IAER,CAAC;GACC,CAAA,IAxFJ,kBAACiE,GAAD;IACE,cAAa;IACb,OAAM;IACN,aAAY;GACb,CAAA;GAwFH,kBAACC,GAAD;IACE,WAAU;IACV,UAAU/C;IACV,eAAeF,EAAa,SAAS,MAAM;IAH7C,UAAA,CAKGE,IACC,kBAACsC,GAAD,EAAS,WAAU,4BAA6B,CAAA,IAEhD,kBAACU,GAAD,EAAQ,WAAU,eAAgB,CAAA,GAEnChD,IAAY,eAAe,cACtB;;EACG;CACT,CAAA,CAAA,EAAA,CAAA;AAEV"}
1
+ {"version":3,"file":"AttachmentsPanel.js","names":["ImageIcon","FileIcon","GET_FILES_QUERY","INITIATE_UPLOAD_MUTATION","COMPLETE_UPLOAD_MUTATION","CREATE_SHARE_LINK_MUTATION","USER_FILE_PERMISSIONS_QUERY","requiresShareCheck","visibility","SHARE_DENIED_TITLE","RESTRICTED_SHARE_DENIED_DESCRIPTION","ROLE_SHARE_DENIED_DESCRIPTION","OTHER_SHARE_DENIED_DESCRIPTION","isShareDenial","access","shareDeniedDescription","denial","shareDenialFor","source","getForbiddenSource","error","formatSize","bytes","units","exponent","extensionOf","dot","fileName","relativeTime","then","iso","diff","sec","min","hr","day","kindOf","mime","file","ext","KIND_STYLE","Image","FileText","FileSpreadsheet","FileArchive","FileCode2","FileVideo","FileAudio","File","FileBadge","kind","tileClass","Icon","iconClass","AttachmentsPanel","fileInputRef","useRef","uploading","setUploading","useState","sharingId","setSharingId","shareAccess","setShareAccess","apolloClient","useApolloClient","useQuery","initiateUpload","useMutation","completeUpload","createShareLink","files","useMemo","data","handleFileSelected","event","initiated","uploadUrl","sessionId","putResponse","notify","refetch","setAccess","useCallback","next","current","fileId","checkCanShare","outcome","isForbiddenError","handleMenuOpenChange","open","handleShare","known","recheck","url","total","hasFiles","Card","CardHeader","CardTitle","Paperclip","CardContent","entityType","entityId","loading","i","isSharing","isChecking","isDenied","Download","Loader2","Lock","Link2","DropdownMenu","DropdownMenuTrigger","MoreHorizontal","DropdownMenuContent","DropdownMenuItem","e","IllustratedEmptyState","Button","Upload"],"sources":["../../../src/assethandler/components/AttachmentsPanel.tsx"],"sourcesContent":["import { useCallback, useMemo, useRef, useState } from 'react';\nimport { gql } from '@apollo/client';\nimport { useApolloClient, useMutation, useQuery } from '@apollo/client/react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from '@burdenoff/fe-libs/ui';\nimport {\n Paperclip,\n Upload,\n Link2,\n FileText,\n FileSpreadsheet,\n FileArchive,\n FileCode2,\n FileVideo,\n FileAudio,\n Image as ImageIcon,\n File as FileIcon,\n Loader2,\n Download,\n MoreHorizontal,\n Lock,\n} from 'lucide-react';\nimport { notify } from '../utils/toast';\nimport { getForbiddenSource, isForbiddenError } from '../utils/graphqlErrors';\n\n/**\n * Attach and reference files for an AssetHandler entity, backed by the\n * federated wspace-files-svc (same wspace gateway/Apollo client). Fully real (no fabricated data) —\n * lists workspace files via `getFiles`, uploads new files through the\n * `initiateUpload` → presigned PUT → `completeUpload` flow, and mints public\n * share links via `createFileShareLink`. All entries are live data.\n *\n * Sharing follows the files service's rules, which depend on visibility:\n *\n * - PRIVATE and TEAM files can only be shared by their uploader or someone\n * holding a SHARE/MANAGE grant or a MANAGER share. For these the panel asks\n * `getUserFilePermissions(fileId).canShare` before minting a link. The check\n * runs lazily (on each desktop click and each time the mobile menu opens), so\n * a long file list never fans out into one permission query per row.\n * - WORKSPACE and PUBLIC files are governed only by the gateway's\n * `@rbac(share, file)` check. `canShare` reflects explicit grants alone for\n * these files, so it is not consulted: the panel calls `createFileShareLink`\n * directly and treats its FORBIDDEN as the denial.\n *\n * The two denials arrive differently and get different copy. The gateway's\n * role check answers HTTP 403 (an Apollo `ServerError`), so the caller is told\n * their workspace role cannot share. The service's per-file rule answers HTTP\n * 200 with a `FORBIDDEN` GraphQL error, so the caller is told who can share\n * the file. A 503 `RBAC_SERVICE_UNAVAILABLE` is an outage, not a denial.\n *\n * `getUserFilePermissions` needs its own `@rbac(read, file-permission)`, which\n * some roles lack. A FORBIDDEN from that query, from either source, means\n * \"unknown\", never \"cannot share\": the panel falls through and lets the share\n * mutation decide.\n *\n * `entityType`/`entityId` scope the panel to the record it is mounted on and\n * are used for context labelling; uploaded files land in the workspace file\n * store so they can be shared and reused.\n */\nexport interface AttachmentsPanelProps {\n entityType: string;\n entityId: string;\n}\n\ninterface FileNode {\n id: string;\n name: string;\n extension: string;\n mimeType: string;\n size: number;\n downloadUrl?: string | null;\n createdAt: string;\n visibility: FileVisibility;\n}\n\ninterface GetFilesResult {\n getFiles: {\n files: FileNode[];\n total: number;\n hasMore: boolean;\n };\n}\n\nconst GET_FILES_QUERY = gql`\n query AssetHandlerGetFiles($pagination: PaginationInput, $sort: FileSortInput) {\n getFiles(pagination: $pagination, sort: $sort) {\n total\n hasMore\n files {\n id\n name\n extension\n mimeType\n size\n downloadUrl\n createdAt\n visibility\n }\n }\n }\n`;\n\nconst INITIATE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerInitiateUpload($input: InitiateUploadInput!) {\n initiateUpload(input: $input) {\n uploadUrl\n session {\n id\n }\n }\n }\n`;\n\nconst COMPLETE_UPLOAD_MUTATION = gql`\n mutation AssetHandlerCompleteUpload($input: CompleteUploadInput!) {\n completeUpload(input: $input) {\n fileId\n file {\n id\n name\n }\n }\n }\n`;\n\nconst CREATE_SHARE_LINK_MUTATION = gql`\n mutation AssetHandlerCreateFileShareLink($input: CreateShareGrantLinkInput!) {\n createFileShareLink(input: $input) {\n id\n shareUrl\n }\n }\n`;\n\nconst USER_FILE_PERMISSIONS_QUERY = gql`\n query AssetHandlerUserFilePermissions($fileId: ID!) {\n getUserFilePermissions(fileId: $fileId) {\n fileId\n canShare\n }\n }\n`;\n\ninterface UserFilePermissionsResult {\n getUserFilePermissions: { fileId: string; canShare: boolean };\n}\n\ntype FileVisibility = 'PRIVATE' | 'TEAM' | 'WORKSPACE' | 'PUBLIC';\n\n/**\n * Why sharing a file was refused, which decides the copy shown:\n *\n * - `denied-role`: the gateway's `@rbac(share, file)` role check (HTTP 403).\n * - `denied-restricted`: the files service's per-file SHARE rule on a PRIVATE\n * or TEAM file, from the `canShare` pre-check or a service FORBIDDEN.\n * - `denied-other`: a service FORBIDDEN on a WORKSPACE or PUBLIC file, which\n * the per-file rule does not cover, so no specific reason can be given.\n */\ntype ShareDenial = 'denied-role' | 'denied-restricted' | 'denied-other';\n\n/**\n * Per-file share state. `unverified` means the permission query itself was\n * FORBIDDEN, so the share mutation is the only authority left to ask.\n */\ntype ShareAccess = 'checking' | 'allowed' | 'unverified' | ShareDenial;\n\n/** Result of one capability check; `failed` is a non-FORBIDDEN query error. */\ntype ShareCheckOutcome = 'allowed' | 'denied-restricted' | 'unverified' | 'failed';\n\n/**\n * Only PRIVATE and TEAM files have a per-file share rule on the server, so only\n * they are pre-checked. WORKSPACE and PUBLIC sharing is decided by the\n * gateway's role check, which `canShare` does not reflect.\n */\nfunction requiresShareCheck(visibility: FileVisibility | null | undefined): boolean {\n return visibility === 'PRIVATE' || visibility === 'TEAM';\n}\n\nconst SHARE_DENIED_TITLE = \"You can't share this file\";\nconst RESTRICTED_SHARE_DENIED_DESCRIPTION =\n 'Only the uploader, or someone with share or manage access to this file, can create a share link. Ask one of them to share it or to grant you access.';\nconst ROLE_SHARE_DENIED_DESCRIPTION =\n \"Your workspace role doesn't allow sharing files. Ask a workspace admin for share access.\";\nconst OTHER_SHARE_DENIED_DESCRIPTION =\n \"You don't have permission to share this file. Ask the file's uploader or a workspace admin.\";\n\nfunction isShareDenial(access: ShareAccess | undefined): access is ShareDenial {\n return access === 'denied-role' || access === 'denied-restricted' || access === 'denied-other';\n}\n\nfunction shareDeniedDescription(denial: ShareDenial): string {\n switch (denial) {\n case 'denied-role':\n return ROLE_SHARE_DENIED_DESCRIPTION;\n case 'denied-restricted':\n return RESTRICTED_SHARE_DENIED_DESCRIPTION;\n case 'denied-other':\n return OTHER_SHARE_DENIED_DESCRIPTION;\n }\n}\n\n/**\n * Maps a FORBIDDEN from `createFileShareLink` to its denial. The gateway's\n * HTTP 403 is always the role check; a service FORBIDDEN is the per-file\n * SHARE rule only for PRIVATE and TEAM files.\n */\nfunction shareDenialFor(\n error: unknown,\n visibility: FileVisibility | null | undefined\n): ShareDenial | null {\n const source = getForbiddenSource(error);\n if (source === 'gateway') return 'denied-role';\n if (source === 'service') {\n return requiresShareCheck(visibility) ? 'denied-restricted' : 'denied-other';\n }\n return null;\n}\n\nfunction formatSize(bytes: number): string {\n if (!bytes) return '0 B';\n const units = ['B', 'KB', 'MB', 'GB'];\n const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);\n const value = bytes / Math.pow(1024, exponent);\n return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;\n}\n\nfunction extensionOf(fileName: string): string {\n const dot = fileName.lastIndexOf('.');\n return dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : 'bin';\n}\n\n/** Compact relative time (\"2 hrs ago\"), with the full timestamp on hover. */\nfunction relativeTime(iso: string): string {\n const then = new Date(iso).getTime();\n if (Number.isNaN(then)) return '';\n const diff = Date.now() - then;\n const sec = Math.round(diff / 1000);\n if (sec < 45) return 'just now';\n const min = Math.round(sec / 60);\n if (min < 60) return `${min} min ago`;\n const hr = Math.round(min / 60);\n if (hr < 24) return `${hr} hr${hr === 1 ? '' : 's'} ago`;\n const day = Math.round(hr / 24);\n if (day < 30) return `${day} day${day === 1 ? '' : 's'} ago`;\n return new Date(iso).toLocaleDateString();\n}\n\ntype FileKind =\n 'image' | 'pdf' | 'sheet' | 'doc' | 'archive' | 'code' | 'video' | 'audio' | 'other';\n\n/** Map a file's extension/mime to a recognisable kind for icon + tint. */\nfunction kindOf(file: FileNode): FileKind {\n const mime = (file.mimeType || '').toLowerCase();\n const ext = (file.extension || extensionOf(file.name)).toLowerCase();\n if (\n mime.startsWith('image/') ||\n ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'bmp'].includes(ext)\n )\n return 'image';\n if (mime.startsWith('video/') || ['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext))\n return 'video';\n if (mime.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(ext))\n return 'audio';\n if (mime === 'application/pdf' || ext === 'pdf') return 'pdf';\n if (['xls', 'xlsx', 'csv', 'tsv', 'ods'].includes(ext)) return 'sheet';\n if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2'].includes(ext)) return 'archive';\n if (\n [\n 'js',\n 'ts',\n 'tsx',\n 'jsx',\n 'json',\n 'xml',\n 'yml',\n 'yaml',\n 'html',\n 'css',\n 'sh',\n 'py',\n 'sql',\n ].includes(ext)\n )\n return 'code';\n if (['doc', 'docx', 'txt', 'md', 'rtf', 'odt', 'pages'].includes(ext)) return 'doc';\n return 'other';\n}\n\nconst KIND_STYLE: Record<\n FileKind,\n { Icon: typeof FileText; iconClass: string; tileClass: string }\n> = {\n image: {\n Icon: ImageIcon,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n pdf: {\n Icon: FileText,\n iconClass: 'text-status-error-text',\n tileClass: 'bg-status-error-bg-subtle',\n },\n sheet: {\n Icon: FileSpreadsheet,\n iconClass: 'text-status-success-text',\n tileClass: 'bg-status-success-bg-subtle',\n },\n doc: {\n Icon: FileText,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n archive: {\n Icon: FileArchive,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n code: {\n Icon: FileCode2,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n video: {\n Icon: FileVideo,\n iconClass: 'text-status-info-text',\n tileClass: 'bg-status-info-bg-subtle',\n },\n audio: {\n Icon: FileAudio,\n iconClass: 'text-status-warning-text',\n tileClass: 'bg-status-warning-bg-subtle',\n },\n other: { Icon: FileIcon, iconClass: 'text-text-secondary', tileClass: 'bg-bg-sunken' },\n};\n\n/** File-type badge tile — an inline thumbnail for images, a tinted glyph otherwise. */\nfunction FileBadge({ file }: { file: FileNode }) {\n const kind = kindOf(file);\n const { Icon, iconClass, tileClass } = KIND_STYLE[kind];\n if (kind === 'image' && file.downloadUrl) {\n return (\n <img\n src={file.downloadUrl}\n alt=\"\"\n loading=\"lazy\"\n className=\"h-10 w-10 shrink-0 rounded-lg border border-border-subtle object-cover\"\n />\n );\n }\n return (\n <span\n className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${tileClass}`}\n aria-hidden=\"true\"\n >\n <Icon className={`h-5 w-5 ${iconClass}`} />\n </span>\n );\n}\n\nexport function AttachmentsPanel({ entityType, entityId }: AttachmentsPanelProps) {\n const fileInputRef = useRef<HTMLInputElement>(null);\n const [uploading, setUploading] = useState(false);\n const [sharingId, setSharingId] = useState<string | null>(null);\n const [shareAccess, setShareAccess] = useState<Record<string, ShareAccess>>({});\n const apolloClient = useApolloClient();\n\n const { data, loading, refetch } = useQuery<GetFilesResult>(GET_FILES_QUERY, {\n variables: {\n pagination: { limit: 25, offset: 0 },\n sort: { field: 'CREATED_AT', order: 'DESC' },\n },\n fetchPolicy: 'cache-and-network',\n errorPolicy: 'all',\n });\n\n const [initiateUpload] = useMutation<{\n initiateUpload: { uploadUrl?: string | null; session: { id: string } };\n }>(INITIATE_UPLOAD_MUTATION);\n const [completeUpload] = useMutation(COMPLETE_UPLOAD_MUTATION);\n const [createShareLink] = useMutation<{\n createFileShareLink: { id: string; shareUrl?: string | null };\n }>(CREATE_SHARE_LINK_MUTATION);\n\n const files = useMemo(() => data?.getFiles.files ?? [], [data]);\n\n const handleFileSelected = async (event: React.ChangeEvent<HTMLInputElement>) => {\n const file = event.target.files?.[0];\n // Reset the input so selecting the same file again re-triggers change.\n event.target.value = '';\n if (!file) return;\n\n setUploading(true);\n try {\n const initiated = await initiateUpload({\n variables: {\n input: {\n fileName: file.name,\n extension: extensionOf(file.name),\n fileSize: file.size,\n mimeType: file.type || 'application/octet-stream',\n visibility: 'WORKSPACE',\n },\n },\n });\n\n const uploadUrl = initiated.data?.initiateUpload.uploadUrl;\n const sessionId = initiated.data?.initiateUpload.session.id;\n if (!uploadUrl || !sessionId) {\n throw new Error('Upload could not be initialised.');\n }\n\n const putResponse = await fetch(uploadUrl, {\n method: 'PUT',\n body: file,\n headers: { 'Content-Type': file.type || 'application/octet-stream' },\n });\n if (!putResponse.ok) {\n throw new Error(`Storage rejected the upload (HTTP ${putResponse.status}).`);\n }\n\n await completeUpload({\n variables: { input: { sessionId, fileSize: file.size } },\n });\n\n notify.success('File uploaded', `${file.name} is now available on this record.`);\n await refetch();\n } catch (error) {\n notify.error(\n 'Upload failed',\n error instanceof Error ? error.message : 'The file could not be uploaded.'\n );\n } finally {\n setUploading(false);\n }\n };\n\n const setAccess = useCallback((fileId: string, access: ShareAccess | null) => {\n setShareAccess((current) => {\n const next = { ...current };\n if (access) next[fileId] = access;\n else delete next[fileId];\n return next;\n });\n }, []);\n\n /**\n * Asks the files service whether the caller may share a PRIVATE or TEAM file.\n * Always hits the network so a grant or revoke made elsewhere is picked up on\n * the next attempt. A FORBIDDEN from the query, whether the gateway's 403 or\n * a service error, means the caller cannot read file permissions, which says\n * nothing about sharing, so it resolves to `unverified` and the share\n * mutation decides. Any other failure (a 503 included) is `failed`.\n */\n const checkCanShare = useCallback(\n async (fileId: string): Promise<ShareCheckOutcome> => {\n setAccess(fileId, 'checking');\n try {\n const result = await apolloClient.query<UserFilePermissionsResult>({\n query: USER_FILE_PERMISSIONS_QUERY,\n variables: { fileId },\n fetchPolicy: 'network-only',\n });\n const outcome =\n result.data?.getUserFilePermissions.canShare === true ? 'allowed' : 'denied-restricted';\n setAccess(fileId, outcome);\n return outcome;\n } catch (error) {\n if (isForbiddenError(error)) {\n setAccess(fileId, 'unverified');\n return 'unverified';\n }\n setAccess(fileId, null);\n return 'failed';\n }\n },\n [apolloClient, setAccess]\n );\n\n const handleMenuOpenChange = (file: FileNode, open: boolean) => {\n if (!open) return;\n if (requiresShareCheck(file.visibility)) {\n if (shareAccess[file.id] !== 'checking') void checkCanShare(file.id);\n } else if (isShareDenial(shareAccess[file.id])) {\n // No pre-check exists for WORKSPACE/PUBLIC files: re-opening the menu\n // re-enables the action so the next attempt asks the server again.\n setAccess(file.id, null);\n }\n };\n\n const handleShare = async (file: FileNode, { recheck }: { recheck: boolean }) => {\n const fileId = file.id;\n setSharingId(fileId);\n try {\n if (requiresShareCheck(file.visibility)) {\n // The mobile menu already checked on open; the desktop action checks on\n // click. Anything other than a settled outcome is checked again here.\n const known = shareAccess[fileId];\n const outcome =\n !recheck && (known === 'allowed' || known === 'unverified')\n ? known\n : await checkCanShare(fileId);\n if (outcome === 'failed') {\n throw new Error('Your share access could not be checked. Please try again.');\n }\n if (outcome === 'denied-restricted') {\n notify.error(SHARE_DENIED_TITLE, shareDeniedDescription(outcome));\n return;\n }\n }\n const result = await createShareLink({\n variables: { input: { resourceId: fileId, role: 'VIEWER' } },\n });\n const url = result.data?.createFileShareLink.shareUrl;\n if (!url) {\n throw new Error('No share link was returned.');\n }\n // The server minted a link, so any earlier denied state is stale.\n setAccess(fileId, 'allowed');\n if (navigator.clipboard?.writeText) {\n await navigator.clipboard.writeText(url);\n notify.success('Share link copied', 'A view-only link is on your clipboard.');\n } else {\n notify.info('Share link ready', url);\n }\n } catch (error) {\n const denial = shareDenialFor(error, file.visibility);\n if (denial) {\n // The mutation is the authoritative denial: the gateway's role check\n // (HTTP 403), or the service's per-file rule, e.g. a grant revoked\n // since the pre-check (HTTP 200 FORBIDDEN).\n setAccess(fileId, denial);\n notify.error(SHARE_DENIED_TITLE, shareDeniedDescription(denial));\n return;\n }\n notify.error(\n 'Could not create share link',\n error instanceof Error ? error.message : 'Please try again.'\n );\n } finally {\n setSharingId(null);\n }\n };\n\n const total = data?.getFiles.total ?? 0;\n const hasFiles = files.length > 0;\n\n return (\n <Card>\n <CardHeader className=\"flex flex-row items-center justify-between gap-2 space-y-0\">\n <CardTitle className=\"flex items-center gap-2 text-base\">\n <Paperclip className=\"h-4 w-4\" />\n Attachments\n {total ? (\n <span className=\"text-sm font-normal text-text-secondary\">\n {total} file{total === 1 ? '' : 's'}\n </span>\n ) : null}\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(event) => void handleFileSelected(event)}\n aria-label={`Upload a file for ${entityType} ${entityId}`}\n />\n\n {loading && !hasFiles ? (\n <div className=\"space-y-2\">\n {Array.from({ length: 3 }).map((_, i) => (\n <div\n key={i}\n className=\"h-14 rounded-lg bg-bg-sunken motion-safe:animate-vc-shimmer\"\n />\n ))}\n </div>\n ) : !hasFiles ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No files yet\"\n description=\"Upload photos, receipts, warranties or documents to keep them with this record.\"\n />\n ) : (\n <ul className=\"space-y-1.5\">\n {files.map((file) => {\n const isSharing = sharingId === file.id;\n const access = shareAccess[file.id];\n const isChecking = access === 'checking';\n const denial = isShareDenial(access) ? access : null;\n const isDenied = denial !== null;\n return (\n <li\n key={file.id}\n className=\"flex items-center gap-3 rounded-lg border border-border-subtle bg-bg-surface p-2.5\"\n >\n <FileBadge file={file} />\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm font-medium\" title={file.name}>\n {file.name}\n </p>\n <p className=\"truncate text-xs text-text-secondary\">\n {formatSize(Number(file.size))} · {relativeTime(file.createdAt)}\n </p>\n </div>\n {/* Desktop: inline actions. Mobile: kebab menu to avoid crowding. */}\n <div className=\"hidden items-center gap-1 sm:flex\">\n {file.downloadUrl ? (\n <a\n href={file.downloadUrl}\n target=\"_blank\"\n rel=\"noreferrer\"\n className=\"rounded-md p-1.5 text-text-secondary transition-colors hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Download ${file.name}`}\n title=\"Download\"\n >\n <Download className=\"h-4 w-4\" />\n </a>\n ) : null}\n {/* A denied action looks disabled (lock icon, muted, not-allowed\n cursor) but stays clickable so the next click asks again. */}\n <button\n type=\"button\"\n disabled={isSharing || isChecking}\n aria-disabled={isDenied ? true : undefined}\n data-share-state={isDenied ? 'denied' : undefined}\n onClick={() => void handleShare(file, { recheck: true })}\n className={`rounded-md p-1.5 transition-colors disabled:opacity-60 ${\n isDenied\n ? 'cursor-not-allowed text-text-tertiary opacity-60'\n : 'text-text-secondary hover:bg-bg-sunken hover:text-text-primary'\n }`}\n aria-label={`Copy share link for ${file.name}`}\n title={\n denial\n ? `${SHARE_DENIED_TITLE}. ${shareDeniedDescription(denial)}`\n : 'Copy share link'\n }\n >\n {isSharing || isChecking ? (\n <Loader2 className=\"h-4 w-4 animate-spin\" />\n ) : isDenied ? (\n <Lock className=\"h-4 w-4\" aria-hidden=\"true\" />\n ) : (\n <Link2 className=\"h-4 w-4\" />\n )}\n </button>\n </div>\n <div className=\"sm:hidden\">\n <DropdownMenu onOpenChange={(open) => handleMenuOpenChange(file, open)}>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n className=\"rounded-md p-1.5 text-text-secondary hover:bg-bg-sunken hover:text-text-primary\"\n aria-label={`Actions for ${file.name}`}\n >\n <MoreHorizontal className=\"h-4 w-4\" />\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n {file.downloadUrl ? (\n <DropdownMenuItem asChild>\n <a href={file.downloadUrl} target=\"_blank\" rel=\"noreferrer\">\n <Download className=\"mr-2 h-4 w-4\" />\n Download\n </a>\n </DropdownMenuItem>\n ) : null}\n <DropdownMenuItem\n disabled={isSharing || isChecking || isDenied}\n onSelect={(e) => {\n e.preventDefault();\n void handleShare(file, { recheck: false });\n }}\n >\n {isChecking ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : isDenied ? (\n <Lock className=\"mr-2 h-4 w-4\" aria-hidden=\"true\" />\n ) : (\n <Link2 className=\"mr-2 h-4 w-4\" />\n )}\n {isChecking\n ? 'Checking share access…'\n : isDenied\n ? 'No share access'\n : 'Copy share link'}\n </DropdownMenuItem>\n {denial ? (\n <p className=\"max-w-56 px-2 py-1.5 text-xs text-text-secondary\">\n {shareDeniedDescription(denial)}\n </p>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Prominent full-width primary upload CTA (matches the reference concept). */}\n <Button\n className=\"w-full\"\n disabled={uploading}\n onClick={() => fileInputRef.current?.click()}\n >\n {uploading ? (\n <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n ) : (\n <Upload className=\"mr-2 h-4 w-4\" />\n )}\n {uploading ? 'Uploading…' : 'Upload files'}\n </Button>\n </CardContent>\n </Card>\n );\n}\n"],"mappings":"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"}
@@ -235,7 +235,7 @@ function B() {
235
235
  children: [/* @__PURE__ */ d(te, { className: "mr-2 h-4 w-4" }), "Edit Budget"]
236
236
  })]
237
237
  }),
238
- /* @__PURE__ */ d(w, { children: "Drill into a single budget to see exactly how it is being consumed: what was allocated, what has been spent, what remains, and every transaction charged against it. Use it to catch overruns early and decide whether to reallocate, cut spend or top up." }),
238
+ /* @__PURE__ */ d(w, { children: "Review the recorded allocation, spent amount, and remaining balance for this budget. Transactions shown below share its category; they are not allocated to this budget and do not automatically change its spent amount. Update the recorded budget totals when reviewing departmental spend." }),
239
239
  /* @__PURE__ */ f("div", {
240
240
  className: "grid gap-4 md:grid-cols-3",
241
241
  children: [
@@ -299,10 +299,10 @@ function B() {
299
299
  ]
300
300
  })]
301
301
  })] }),
302
- /* @__PURE__ */ f(h, { children: [/* @__PURE__ */ f(v, { children: [/* @__PURE__ */ d(y, { children: "Related Transactions" }), /* @__PURE__ */ f(_, { children: [
303
- "Transactions in ",
302
+ /* @__PURE__ */ f(h, { children: [/* @__PURE__ */ f(v, { children: [/* @__PURE__ */ d(y, { children: "Transactions in the same category" }), /* @__PURE__ */ f(_, { children: [
303
+ "Workspace transactions in ",
304
304
  K.category,
305
- " category"
305
+ "; category matches do not establish a budget allocation."
306
306
  ] })] }), /* @__PURE__ */ d(g, {
307
307
  className: "p-0",
308
308
  children: /* @__PURE__ */ f("div", {
@@ -364,8 +364,8 @@ function B() {
364
364
  }, e.id)) })]
365
365
  }), Q.length === 0 && /* @__PURE__ */ d(ne, {
366
366
  illustration: "empty-data",
367
- title: "No related transactions",
368
- description: `No spend has been charged to the ${K.category} category yet.`
367
+ title: "No transactions in this category",
368
+ description: `No workspace transactions have been recorded in the ${K.category} category yet.`
369
369
  })]
370
370
  })
371
371
  })] })
@@ -1 +1 @@
1
- {"version":3,"file":"FinanceBudgetDetailPage.js","names":["BUDGET_QUERY","UPDATE_BUDGET_MUTATION","BUDGET_STATUSES","humanize","value","c","statusVariant","status","FinanceBudgetDetailPage","money","useAssetMoney","navigate","useAssetHandlerNavigate","useParams","useAssetHandlerStore","useQuery","budgetId","budget","data","editOpen","setEditOpen","useState","form","setForm","openEdit","updateBudget","saving","useMutation","notify","refetch","error","handleSave","parsedAllocated","parsedSpent","loading","Loader2","NotFoundState","relatedTxns","transactions","t","utilizationPct","ResponsiveDialog","ResponsiveDialogContent","ResponsiveDialogHeader","ResponsiveDialogTitle","ResponsiveDialogDescription","Label","Input","p","Select","v","SelectTrigger","SelectValue","SelectContent","SelectItem","s","ResponsiveDialogFooter","Button","ArrowLeft","GradientText","Badge","Edit","PagePurpose","Card","CardContent","EmphasisPanel","CardHeader","CardTitle","CardDescription","txn","IllustratedEmptyState"],"sources":["../../../src/assethandler/pages/FinanceBudgetDetailPage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { useAssetMoney } from '../providers/AssetHandlerProvider';\nimport { useAssetHandlerNavigate } from '../hooks/useAssetHandlerNavigate';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n Input,\n Label,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n GradientText,\n EmphasisPanel,\n PagePurpose,\n IllustratedEmptyState,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { ArrowLeft, Edit, Loader2 } from 'lucide-react';\nimport { useParams } from 'react-router';\nimport { gql } from '@apollo/client';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport { useAssetHandlerStore } from '../store/assethandlerStore';\nimport { NotFoundState } from '../components';\nimport { notify } from '../utils/toast';\n\n// ─── Backend types ────────────────────────────────────────────────────────────\ntype AssetBudgetStatus = 'ON_TRACK' | 'OVER_BUDGET' | 'UNDER_BUDGET';\n\ninterface AssetBudget {\n id: string;\n name: string;\n category: string;\n allocated: number;\n spent: number;\n remaining: number;\n period: string;\n department: string;\n status: AssetBudgetStatus;\n createdAt: string;\n updatedAt: string;\n}\n\nconst BUDGET_QUERY = gql`\n query AssetHandlerBudget($id: ID!) {\n budget(id: $id) {\n id\n name\n category\n allocated\n spent\n remaining\n period\n department\n status\n createdAt\n updatedAt\n }\n }\n`;\n\nconst UPDATE_BUDGET_MUTATION = gql`\n mutation AssetHandlerUpdateBudget($id: ID!, $input: AssetHandlerUpdateBudgetInput!) {\n updateBudget(id: $id, input: $input) {\n id\n name\n }\n }\n`;\n\ninterface BudgetQueryResult {\n budget: AssetBudget | null;\n}\ninterface UpdateBudgetResult {\n updateBudget: { id: string; name: string };\n}\n\nconst BUDGET_STATUSES: AssetBudgetStatus[] = ['ON_TRACK', 'OVER_BUDGET', 'UNDER_BUDGET'];\n\nconst humanize = (value: string) =>\n value\n .toLowerCase()\n .replace(/_/g, ' ')\n .replace(/^\\w/, (c) => c.toUpperCase());\n\nconst statusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' =>\n status === 'OVER_BUDGET' ? 'destructive' : status === 'UNDER_BUDGET' ? 'secondary' : 'default';\n\nexport function FinanceBudgetDetailPage() {\n const { format: money } = useAssetMoney();\n const navigate = useAssetHandlerNavigate();\n const { budgetId } = useParams<{ budgetId: string }>();\n const { transactions } = useAssetHandlerStore();\n\n const { data, loading, refetch } = useQuery<BudgetQueryResult>(BUDGET_QUERY, {\n variables: { id: budgetId ?? '' },\n skip: !budgetId,\n fetchPolicy: 'cache-and-network',\n });\n\n const budget = data?.budget ?? null;\n\n const [editOpen, setEditOpen] = useState(false);\n const [form, setForm] = useState({\n name: '',\n category: '',\n department: '',\n period: '',\n allocated: '',\n spent: '',\n status: 'ON_TRACK' as AssetBudgetStatus,\n });\n\n const openEdit = () => {\n if (!budget) return;\n setForm({\n name: budget.name,\n category: budget.category,\n department: budget.department,\n period: budget.period,\n allocated: String(budget.allocated),\n spent: String(budget.spent),\n status: budget.status,\n });\n setEditOpen(true);\n };\n\n const [updateBudget, { loading: saving }] = useMutation<UpdateBudgetResult>(\n UPDATE_BUDGET_MUTATION,\n {\n refetchQueries: ['AssetHandlerBootstrap'],\n onCompleted: () => {\n notify.success('Budget updated', 'Your changes have been saved.');\n setEditOpen(false);\n void refetch();\n },\n onError: (error) => notify.error('Could not update budget', error.message),\n }\n );\n\n const handleSave = () => {\n if (!budgetId) return;\n const parsedAllocated = Number(form.allocated);\n const parsedSpent = Number(form.spent);\n if (\n !form.name.trim() ||\n !form.category.trim() ||\n !form.department.trim() ||\n !form.period.trim()\n ) {\n notify.warning('Missing details', 'Name, category, department and period are required.');\n return;\n }\n if (!Number.isFinite(parsedAllocated) || !Number.isFinite(parsedSpent)) {\n notify.warning('Invalid numbers', 'Allocated and spent must be numeric.');\n return;\n }\n void updateBudget({\n variables: {\n id: budgetId,\n input: {\n name: form.name.trim(),\n category: form.category.trim(),\n department: form.department.trim(),\n period: form.period.trim(),\n allocated: parsedAllocated,\n spent: parsedSpent,\n status: form.status,\n },\n },\n });\n };\n\n if (loading && !budget) {\n return (\n <div className=\"flex min-h-[320px] items-center justify-center p-6\">\n <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n </div>\n );\n }\n\n if (!budget) {\n return (\n <NotFoundState\n entityLabel=\"Budget\"\n onBack={() => navigate('/finance/finance-hub')}\n backLabel=\"Back to finance\"\n />\n );\n }\n\n const relatedTxns = transactions.filter((t) => t.category === budget.category);\n const utilizationPct = budget.allocated ? Math.round((budget.spent / budget.allocated) * 100) : 0;\n\n return (\n <div className=\"space-y-6 p-6\">\n <ResponsiveDialog open={editOpen} onOpenChange={setEditOpen}>\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit Budget</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Update the allocation and status for this budget.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <Label>Name *</Label>\n <Input\n value={form.name}\n onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}\n />\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Category *</Label>\n <Input\n value={form.category}\n onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Department *</Label>\n <Input\n value={form.department}\n onChange={(e) => setForm((p) => ({ ...p, department: e.target.value }))}\n />\n </div>\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Period *</Label>\n <Input\n value={form.period}\n onChange={(e) => setForm((p) => ({ ...p, period: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Status</Label>\n <Select\n value={form.status}\n onValueChange={(v) => setForm((p) => ({ ...p, status: v as AssetBudgetStatus }))}\n >\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {BUDGET_STATUSES.map((s) => (\n <SelectItem key={s} value={s}>\n {humanize(s)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Allocated *</Label>\n <Input\n type=\"number\"\n value={form.allocated}\n onChange={(e) => setForm((p) => ({ ...p, allocated: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Spent *</Label>\n <Input\n type=\"number\"\n value={form.spent}\n onChange={(e) => setForm((p) => ({ ...p, spent: e.target.value }))}\n />\n </div>\n </div>\n </div>\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => setEditOpen(false)} disabled={saving}>\n Cancel\n </Button>\n <Button onClick={handleSave} disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : null}\n {saving ? 'Saving…' : 'Save Changes'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-4\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n aria-label=\"Go back\"\n onClick={() => navigate('/finance/finance-hub')}\n >\n <ArrowLeft className=\"h-4 w-4\" />\n </Button>\n <div>\n <div className=\"flex items-center gap-3\">\n <h1 className=\"text-2xl font-bold\">\n <GradientText>{budget.name}</GradientText>\n </h1>\n <Badge variant={statusVariant(budget.status)}>{humanize(budget.status)}</Badge>\n </div>\n <p className=\"text-sm text-text-secondary mt-1\">\n {budget.department} &middot; {budget.period}\n </p>\n </div>\n </div>\n <Button size=\"sm\" onClick={openEdit}>\n <Edit className=\"mr-2 h-4 w-4\" />\n Edit Budget\n </Button>\n </div>\n\n <PagePurpose>\n Drill into a single budget to see exactly how it is being consumed: what was allocated, what\n has been spent, what remains, and every transaction charged against it. Use it to catch\n overruns early and decide whether to reallocate, cut spend or top up.\n </PagePurpose>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Allocated</p>\n <p className=\"text-2xl font-bold tabular-nums\">{money(budget.allocated)}</p>\n </CardContent>\n </Card>\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Spent</p>\n <p className=\"text-2xl font-bold tabular-nums\">{money(budget.spent)}</p>\n <p className=\"text-xs text-text-secondary\">{utilizationPct}% utilized</p>\n </CardContent>\n </Card>\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Remaining</p>\n <p\n className={`text-2xl font-bold ${budget.remaining < 0 ? 'text-status-error-text' : 'text-status-success-text'}`}\n >\n {money(budget.remaining)}\n </p>\n </CardContent>\n </Card>\n </div>\n\n <EmphasisPanel>\n <CardHeader className=\"px-0 pt-0\">\n <CardTitle>Budget Utilization</CardTitle>\n </CardHeader>\n <CardContent className=\"px-0 pb-0\">\n <div className=\"h-4 rounded-full bg-bg-sunken overflow-hidden\">\n <div\n className={`h-full rounded-full transition-all ${budget.status === 'OVER_BUDGET' ? 'bg-status-error-bg' : 'bg-action-primary-bg'}`}\n style={{ width: `${Math.min(utilizationPct, 100)}%` }}\n />\n </div>\n <p className=\"text-sm text-text-secondary mt-2\">\n {utilizationPct}% of {money(budget.allocated)} allocated budget consumed\n </p>\n </CardContent>\n </EmphasisPanel>\n\n <Card>\n <CardHeader>\n <CardTitle>Related Transactions</CardTitle>\n <CardDescription>Transactions in {budget.category} category</CardDescription>\n </CardHeader>\n <CardContent className=\"p-0\">\n <div className=\"overflow-x-auto\">\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-b bg-bg-sunken/50\">\n <th className=\"text-left p-3 font-medium\">ID</th>\n <th className=\"text-left p-3 font-medium\">Description</th>\n <th className=\"text-right p-3 font-medium\">Amount</th>\n <th className=\"text-left p-3 font-medium\">Status</th>\n <th className=\"text-left p-3 font-medium\">Date</th>\n </tr>\n </thead>\n <tbody>\n {relatedTxns.map((txn) => (\n <tr\n key={txn.id}\n className=\"border-b hover:bg-bg-sunken/50 cursor-pointer\"\n onClick={() => navigate('/transactions/' + txn.id)}\n >\n <td className=\"p-3 font-mono text-xs\">{txn.id}</td>\n <td className=\"p-3 truncate max-w-xs\">{txn.description}</td>\n <td className=\"p-3 text-right font-medium\">{money(Math.abs(txn.amount))}</td>\n <td className=\"p-3\">\n <Badge variant=\"outline\">{txn.status}</Badge>\n </td>\n <td className=\"p-3 text-xs\">{new Date(txn.date).toLocaleDateString()}</td>\n </tr>\n ))}\n </tbody>\n </table>\n {relatedTxns.length === 0 && (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No related transactions\"\n description={`No spend has been charged to the ${budget.category} category yet.`}\n />\n )}\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"FinanceBudgetDetailPage.js","names":["BUDGET_QUERY","UPDATE_BUDGET_MUTATION","BUDGET_STATUSES","humanize","value","c","statusVariant","status","FinanceBudgetDetailPage","money","useAssetMoney","navigate","useAssetHandlerNavigate","useParams","useAssetHandlerStore","useQuery","budgetId","budget","data","editOpen","setEditOpen","useState","form","setForm","openEdit","updateBudget","saving","useMutation","notify","refetch","error","handleSave","parsedAllocated","parsedSpent","loading","Loader2","NotFoundState","relatedTxns","transactions","t","utilizationPct","ResponsiveDialog","ResponsiveDialogContent","ResponsiveDialogHeader","ResponsiveDialogTitle","ResponsiveDialogDescription","Label","Input","p","Select","v","SelectTrigger","SelectValue","SelectContent","SelectItem","s","ResponsiveDialogFooter","Button","ArrowLeft","GradientText","Badge","Edit","PagePurpose","Card","CardContent","EmphasisPanel","CardHeader","CardTitle","CardDescription","txn","IllustratedEmptyState"],"sources":["../../../src/assethandler/pages/FinanceBudgetDetailPage.tsx"],"sourcesContent":["import { useState } from 'react';\nimport { useAssetMoney } from '../providers/AssetHandlerProvider';\nimport { useAssetHandlerNavigate } from '../hooks/useAssetHandlerNavigate';\nimport {\n Badge,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n Input,\n Label,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n GradientText,\n EmphasisPanel,\n PagePurpose,\n IllustratedEmptyState,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n} from '@burdenoff/fe-libs/ui';\nimport { ArrowLeft, Edit, Loader2 } from 'lucide-react';\nimport { useParams } from 'react-router';\nimport { gql } from '@apollo/client';\nimport { useMutation, useQuery } from '@apollo/client/react';\nimport { useAssetHandlerStore } from '../store/assethandlerStore';\nimport { NotFoundState } from '../components';\nimport { notify } from '../utils/toast';\n\n// ─── Backend types ────────────────────────────────────────────────────────────\ntype AssetBudgetStatus = 'ON_TRACK' | 'OVER_BUDGET' | 'UNDER_BUDGET';\n\ninterface AssetBudget {\n id: string;\n name: string;\n category: string;\n allocated: number;\n spent: number;\n remaining: number;\n period: string;\n department: string;\n status: AssetBudgetStatus;\n createdAt: string;\n updatedAt: string;\n}\n\nconst BUDGET_QUERY = gql`\n query AssetHandlerBudget($id: ID!) {\n budget(id: $id) {\n id\n name\n category\n allocated\n spent\n remaining\n period\n department\n status\n createdAt\n updatedAt\n }\n }\n`;\n\nconst UPDATE_BUDGET_MUTATION = gql`\n mutation AssetHandlerUpdateBudget($id: ID!, $input: AssetHandlerUpdateBudgetInput!) {\n updateBudget(id: $id, input: $input) {\n id\n name\n }\n }\n`;\n\ninterface BudgetQueryResult {\n budget: AssetBudget | null;\n}\ninterface UpdateBudgetResult {\n updateBudget: { id: string; name: string };\n}\n\nconst BUDGET_STATUSES: AssetBudgetStatus[] = ['ON_TRACK', 'OVER_BUDGET', 'UNDER_BUDGET'];\n\nconst humanize = (value: string) =>\n value\n .toLowerCase()\n .replace(/_/g, ' ')\n .replace(/^\\w/, (c) => c.toUpperCase());\n\nconst statusVariant = (status: string): 'default' | 'secondary' | 'destructive' | 'outline' =>\n status === 'OVER_BUDGET' ? 'destructive' : status === 'UNDER_BUDGET' ? 'secondary' : 'default';\n\nexport function FinanceBudgetDetailPage() {\n const { format: money } = useAssetMoney();\n const navigate = useAssetHandlerNavigate();\n const { budgetId } = useParams<{ budgetId: string }>();\n const { transactions } = useAssetHandlerStore();\n\n const { data, loading, refetch } = useQuery<BudgetQueryResult>(BUDGET_QUERY, {\n variables: { id: budgetId ?? '' },\n skip: !budgetId,\n fetchPolicy: 'cache-and-network',\n });\n\n const budget = data?.budget ?? null;\n\n const [editOpen, setEditOpen] = useState(false);\n const [form, setForm] = useState({\n name: '',\n category: '',\n department: '',\n period: '',\n allocated: '',\n spent: '',\n status: 'ON_TRACK' as AssetBudgetStatus,\n });\n\n const openEdit = () => {\n if (!budget) return;\n setForm({\n name: budget.name,\n category: budget.category,\n department: budget.department,\n period: budget.period,\n allocated: String(budget.allocated),\n spent: String(budget.spent),\n status: budget.status,\n });\n setEditOpen(true);\n };\n\n const [updateBudget, { loading: saving }] = useMutation<UpdateBudgetResult>(\n UPDATE_BUDGET_MUTATION,\n {\n refetchQueries: ['AssetHandlerBootstrap'],\n onCompleted: () => {\n notify.success('Budget updated', 'Your changes have been saved.');\n setEditOpen(false);\n void refetch();\n },\n onError: (error) => notify.error('Could not update budget', error.message),\n }\n );\n\n const handleSave = () => {\n if (!budgetId) return;\n const parsedAllocated = Number(form.allocated);\n const parsedSpent = Number(form.spent);\n if (\n !form.name.trim() ||\n !form.category.trim() ||\n !form.department.trim() ||\n !form.period.trim()\n ) {\n notify.warning('Missing details', 'Name, category, department and period are required.');\n return;\n }\n if (!Number.isFinite(parsedAllocated) || !Number.isFinite(parsedSpent)) {\n notify.warning('Invalid numbers', 'Allocated and spent must be numeric.');\n return;\n }\n void updateBudget({\n variables: {\n id: budgetId,\n input: {\n name: form.name.trim(),\n category: form.category.trim(),\n department: form.department.trim(),\n period: form.period.trim(),\n allocated: parsedAllocated,\n spent: parsedSpent,\n status: form.status,\n },\n },\n });\n };\n\n if (loading && !budget) {\n return (\n <div className=\"flex min-h-[320px] items-center justify-center p-6\">\n <Loader2 className=\"h-8 w-8 animate-spin text-primary\" />\n </div>\n );\n }\n\n if (!budget) {\n return (\n <NotFoundState\n entityLabel=\"Budget\"\n onBack={() => navigate('/finance/finance-hub')}\n backLabel=\"Back to finance\"\n />\n );\n }\n\n const relatedTxns = transactions.filter((t) => t.category === budget.category);\n const utilizationPct = budget.allocated ? Math.round((budget.spent / budget.allocated) * 100) : 0;\n\n return (\n <div className=\"space-y-6 p-6\">\n <ResponsiveDialog open={editOpen} onOpenChange={setEditOpen}>\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>Edit Budget</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Update the allocation and status for this budget.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n <div className=\"space-y-4\">\n <div className=\"space-y-2\">\n <Label>Name *</Label>\n <Input\n value={form.name}\n onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}\n />\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Category *</Label>\n <Input\n value={form.category}\n onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Department *</Label>\n <Input\n value={form.department}\n onChange={(e) => setForm((p) => ({ ...p, department: e.target.value }))}\n />\n </div>\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Period *</Label>\n <Input\n value={form.period}\n onChange={(e) => setForm((p) => ({ ...p, period: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Status</Label>\n <Select\n value={form.status}\n onValueChange={(v) => setForm((p) => ({ ...p, status: v as AssetBudgetStatus }))}\n >\n <SelectTrigger>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {BUDGET_STATUSES.map((s) => (\n <SelectItem key={s} value={s}>\n {humanize(s)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n </div>\n <div className=\"grid gap-4 md:grid-cols-2\">\n <div className=\"space-y-2\">\n <Label>Allocated *</Label>\n <Input\n type=\"number\"\n value={form.allocated}\n onChange={(e) => setForm((p) => ({ ...p, allocated: e.target.value }))}\n />\n </div>\n <div className=\"space-y-2\">\n <Label>Spent *</Label>\n <Input\n type=\"number\"\n value={form.spent}\n onChange={(e) => setForm((p) => ({ ...p, spent: e.target.value }))}\n />\n </div>\n </div>\n </div>\n <ResponsiveDialogFooter>\n <Button variant=\"outline\" onClick={() => setEditOpen(false)} disabled={saving}>\n Cancel\n </Button>\n <Button onClick={handleSave} disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" /> : null}\n {saving ? 'Saving…' : 'Save Changes'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n\n <div className=\"flex items-center justify-between\">\n <div className=\"flex items-center gap-4\">\n <Button\n variant=\"ghost\"\n size=\"icon\"\n aria-label=\"Go back\"\n onClick={() => navigate('/finance/finance-hub')}\n >\n <ArrowLeft className=\"h-4 w-4\" />\n </Button>\n <div>\n <div className=\"flex items-center gap-3\">\n <h1 className=\"text-2xl font-bold\">\n <GradientText>{budget.name}</GradientText>\n </h1>\n <Badge variant={statusVariant(budget.status)}>{humanize(budget.status)}</Badge>\n </div>\n <p className=\"text-sm text-text-secondary mt-1\">\n {budget.department} &middot; {budget.period}\n </p>\n </div>\n </div>\n <Button size=\"sm\" onClick={openEdit}>\n <Edit className=\"mr-2 h-4 w-4\" />\n Edit Budget\n </Button>\n </div>\n\n <PagePurpose>\n Review the recorded allocation, spent amount, and remaining balance for this budget.\n Transactions shown below share its category; they are not allocated to this budget and do\n not automatically change its spent amount. Update the recorded budget totals when reviewing\n departmental spend.\n </PagePurpose>\n\n <div className=\"grid gap-4 md:grid-cols-3\">\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Allocated</p>\n <p className=\"text-2xl font-bold tabular-nums\">{money(budget.allocated)}</p>\n </CardContent>\n </Card>\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Spent</p>\n <p className=\"text-2xl font-bold tabular-nums\">{money(budget.spent)}</p>\n <p className=\"text-xs text-text-secondary\">{utilizationPct}% utilized</p>\n </CardContent>\n </Card>\n <Card>\n <CardContent className=\"pt-4\">\n <p className=\"text-sm text-text-secondary\">Remaining</p>\n <p\n className={`text-2xl font-bold ${budget.remaining < 0 ? 'text-status-error-text' : 'text-status-success-text'}`}\n >\n {money(budget.remaining)}\n </p>\n </CardContent>\n </Card>\n </div>\n\n <EmphasisPanel>\n <CardHeader className=\"px-0 pt-0\">\n <CardTitle>Budget Utilization</CardTitle>\n </CardHeader>\n <CardContent className=\"px-0 pb-0\">\n <div className=\"h-4 rounded-full bg-bg-sunken overflow-hidden\">\n <div\n className={`h-full rounded-full transition-all ${budget.status === 'OVER_BUDGET' ? 'bg-status-error-bg' : 'bg-action-primary-bg'}`}\n style={{ width: `${Math.min(utilizationPct, 100)}%` }}\n />\n </div>\n <p className=\"text-sm text-text-secondary mt-2\">\n {utilizationPct}% of {money(budget.allocated)} allocated budget consumed\n </p>\n </CardContent>\n </EmphasisPanel>\n\n <Card>\n <CardHeader>\n <CardTitle>Transactions in the same category</CardTitle>\n <CardDescription>\n Workspace transactions in {budget.category}; category matches do not establish a budget\n allocation.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"p-0\">\n <div className=\"overflow-x-auto\">\n <table className=\"w-full text-sm\">\n <thead>\n <tr className=\"border-b bg-bg-sunken/50\">\n <th className=\"text-left p-3 font-medium\">ID</th>\n <th className=\"text-left p-3 font-medium\">Description</th>\n <th className=\"text-right p-3 font-medium\">Amount</th>\n <th className=\"text-left p-3 font-medium\">Status</th>\n <th className=\"text-left p-3 font-medium\">Date</th>\n </tr>\n </thead>\n <tbody>\n {relatedTxns.map((txn) => (\n <tr\n key={txn.id}\n className=\"border-b hover:bg-bg-sunken/50 cursor-pointer\"\n onClick={() => navigate('/transactions/' + txn.id)}\n >\n <td className=\"p-3 font-mono text-xs\">{txn.id}</td>\n <td className=\"p-3 truncate max-w-xs\">{txn.description}</td>\n <td className=\"p-3 text-right font-medium\">{money(Math.abs(txn.amount))}</td>\n <td className=\"p-3\">\n <Badge variant=\"outline\">{txn.status}</Badge>\n </td>\n <td className=\"p-3 text-xs\">{new Date(txn.date).toLocaleDateString()}</td>\n </tr>\n ))}\n </tbody>\n </table>\n {relatedTxns.length === 0 && (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No transactions in this category\"\n description={`No workspace transactions have been recorded in the ${budget.category} category yet.`}\n />\n )}\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where a FORBIDDEN denial came from.
3
+ *
4
+ * - `gateway`: the API gateway's `@rbac` check rejected the operation. The
5
+ * gateway answers with HTTP 403 and a JSON body, which Apollo Client 4
6
+ * surfaces as a `ServerError` (`statusCode` + `bodyText`, no `errors` array).
7
+ * This is a role decision: the caller's workspace role lacks the action.
8
+ * - `service`: the operation passed the gateway and the subgraph itself
9
+ * refused it. That arrives as HTTP 200 with `errors[].extensions.code`
10
+ * `FORBIDDEN` (`CombinedGraphQLErrors`, or `graphQLErrors` on older shapes).
11
+ * For files this is the per-file SHARE rule on PRIVATE and TEAM files.
12
+ */
13
+ export type ForbiddenSource = 'gateway' | 'service';
14
+ /**
15
+ * Classifies a thrown Apollo error as a FORBIDDEN denial and says where it came
16
+ * from, or returns `null` when it is not a denial. Checks are structural so the
17
+ * helper does not depend on Apollo's error class identity.
18
+ *
19
+ * Only HTTP 403 counts at the transport level. Any other status, notably a 503
20
+ * `RBAC_SERVICE_UNAVAILABLE` from the gateway, is an outage and never a denial,
21
+ * even if its body happens to mention FORBIDDEN.
22
+ */
23
+ export declare function getForbiddenSource(error: unknown): ForbiddenSource | null;
24
+ /** True when a thrown Apollo error is a FORBIDDEN denial from any source. */
25
+ export declare function isForbiddenError(error: unknown): boolean;
@@ -0,0 +1,37 @@
1
+ function e(e) {
2
+ return Array.isArray(e) && e.some((e) => typeof e != "object" || !e ? !1 : e.extensions?.code === "FORBIDDEN");
3
+ }
4
+ function t(e) {
5
+ let t = [e, e.networkError];
6
+ for (let e of t) {
7
+ if (typeof e != "object" || !e) continue;
8
+ let { statusCode: t, bodyText: n, result: r } = e;
9
+ if (typeof t == "number") return {
10
+ status: t,
11
+ body: n ?? r
12
+ };
13
+ }
14
+ return null;
15
+ }
16
+ function n(t) {
17
+ let n = t;
18
+ if (typeof t == "string") try {
19
+ n = JSON.parse(t);
20
+ } catch {
21
+ return !0;
22
+ }
23
+ if (typeof n != "object" || !n) return !0;
24
+ let r = n.errors;
25
+ return !Array.isArray(r) || e(r);
26
+ }
27
+ function r(r) {
28
+ if (typeof r != "object" || !r) return null;
29
+ let i = t(r);
30
+ return i ? i.status === 403 && n(i.body) ? "gateway" : null : [r.errors, r.graphQLErrors].some(e) ? "service" : null;
31
+ }
32
+ function i(e) {
33
+ return r(e) !== null;
34
+ }
35
+ export { r as getForbiddenSource, i as isForbiddenError };
36
+
37
+ //# sourceMappingURL=graphqlErrors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graphqlErrors.js","names":["hasForbiddenCode","list","entry","httpFailure","sources","error","source","statusCode","bodyText","result","isForbiddenBody","parsed","body","errors","getForbiddenSource","http","isForbiddenError"],"sources":["../../../src/assethandler/utils/graphqlErrors.ts"],"sourcesContent":["/**\n * Where a FORBIDDEN denial came from.\n *\n * - `gateway`: the API gateway's `@rbac` check rejected the operation. The\n * gateway answers with HTTP 403 and a JSON body, which Apollo Client 4\n * surfaces as a `ServerError` (`statusCode` + `bodyText`, no `errors` array).\n * This is a role decision: the caller's workspace role lacks the action.\n * - `service`: the operation passed the gateway and the subgraph itself\n * refused it. That arrives as HTTP 200 with `errors[].extensions.code`\n * `FORBIDDEN` (`CombinedGraphQLErrors`, or `graphQLErrors` on older shapes).\n * For files this is the per-file SHARE rule on PRIVATE and TEAM files.\n */\nexport type ForbiddenSource = 'gateway' | 'service';\n\nconst FORBIDDEN_CODE = 'FORBIDDEN';\nconst HTTP_FORBIDDEN = 403;\n\nfunction hasForbiddenCode(list: unknown): boolean {\n return (\n Array.isArray(list) &&\n list.some((entry) => {\n if (typeof entry !== 'object' || entry === null) return false;\n return (entry as { extensions?: { code?: unknown } }).extensions?.code === FORBIDDEN_CODE;\n })\n );\n}\n\n/**\n * Reads the HTTP status and body of a transport-level error: Apollo 4's\n * `ServerError` carries them directly, Apollo 3 nested them under\n * `networkError` (`statusCode`, `bodyText` on a `ServerParseError`, or\n * `result` on a `ServerError`).\n */\nfunction httpFailure(error: object): { status: number; body: unknown } | null {\n const sources: unknown[] = [error, (error as { networkError?: unknown }).networkError];\n for (const source of sources) {\n if (typeof source !== 'object' || source === null) continue;\n const { statusCode, bodyText, result } = source as {\n statusCode?: unknown;\n bodyText?: unknown;\n result?: unknown;\n };\n if (typeof statusCode !== 'number') continue;\n return { status: statusCode, body: bodyText ?? result };\n }\n return null;\n}\n\n/**\n * Decides whether an HTTP 403 body is a gateway FORBIDDEN. A body that parses\n * to a GraphQL `errors` array must carry the FORBIDDEN code; a body that does\n * not parse to that shape leaves the 403 status as the only signal, which is\n * still a denial.\n */\nfunction isForbiddenBody(body: unknown): boolean {\n let parsed: unknown = body;\n if (typeof body === 'string') {\n try {\n parsed = JSON.parse(body);\n } catch {\n return true;\n }\n }\n if (typeof parsed !== 'object' || parsed === null) return true;\n const errors = (parsed as { errors?: unknown }).errors;\n if (!Array.isArray(errors)) return true;\n return hasForbiddenCode(errors);\n}\n\n/**\n * Classifies a thrown Apollo error as a FORBIDDEN denial and says where it came\n * from, or returns `null` when it is not a denial. Checks are structural so the\n * helper does not depend on Apollo's error class identity.\n *\n * Only HTTP 403 counts at the transport level. Any other status, notably a 503\n * `RBAC_SERVICE_UNAVAILABLE` from the gateway, is an outage and never a denial,\n * even if its body happens to mention FORBIDDEN.\n */\nexport function getForbiddenSource(error: unknown): ForbiddenSource | null {\n if (typeof error !== 'object' || error === null) return null;\n\n const http = httpFailure(error);\n if (http) {\n return http.status === HTTP_FORBIDDEN && isForbiddenBody(http.body) ? 'gateway' : null;\n }\n\n const lists = [\n (error as { errors?: unknown }).errors,\n (error as { graphQLErrors?: unknown }).graphQLErrors,\n ];\n return lists.some(hasForbiddenCode) ? 'service' : null;\n}\n\n/** True when a thrown Apollo error is a FORBIDDEN denial from any source. */\nexport function isForbiddenError(error: unknown): boolean {\n return getForbiddenSource(error) !== null;\n}\n"],"mappings":"AAiBA,SAASA,EAAiB,GAAwB;CAChD,OACE,MAAM,QAAQC,CAAI,KAClBA,EAAK,MAAM,MACL,OAAOC,KAAU,aAAYA,IAAuB,KAChDA,EAA8C,YAAY,SAAS,WAC5E;AAEL;AAQA,SAASC,EAAY,GAAyD;CAC5E,IAAMC,IAAqB,CAACC,GAAQA,EAAqC,YAAY;CACrF,KAAK,IAAMC,KAAUF,GAAS;EAC5B,IAAI,OAAOE,KAAW,aAAYA,GAAiB;EACnD,IAAM,EAAE,eAAY,aAAU,cAAWA;EAKzC,IAAI,OAAOC,KAAe,UAC1B,OAAO;GAAE,QAAQA;GAAY,MAAMC,KAAYC;EAAO;CACxD;CACA,OAAO;AACT;AAQA,SAASC,EAAgB,GAAwB;CAC/C,IAAIC,IAAkBC;CACtB,IAAI,OAAOA,KAAS,UAClB,IAAI;EACF,IAAS,KAAK,MAAMA,CAAI;CAC1B,QAAQ;EACN,OAAO;CACT;CAEF,IAAI,OAAOD,KAAW,aAAYA,GAAiB,OAAO;CAC1D,IAAME,IAAUF,EAAgC;CAEhD,OADA,CAAK,MAAM,QAAQE,CAAM,KAClBb,EAAiBa,CAAM;AAChC;AAWA,SAAgBC,EAAmB,GAAwC;CACzE,IAAI,OAAOT,KAAU,aAAYA,GAAgB,OAAO;CAExD,IAAMU,IAAOZ,EAAYE,CAAK;CAS9B,OARIU,IACKA,EAAK,WAAW,OAAkBL,EAAgBK,EAAK,IAAI,IAAI,YAAY,OAO7E,CAHJV,EAA+B,QAC/BA,EAAsC,aAElC,CAAA,CAAM,KAAKL,CAAgB,IAAI,YAAY;AACpD;AAGA,SAAgBgB,EAAiB,GAAyB;CACxD,OAAOF,EAAmBT,CAAK,MAAM;AACvC"}
@@ -48,6 +48,7 @@ export declare const Input: FC<{
48
48
  }>;
49
49
  export declare const DropdownMenu: FC<{
50
50
  children?: ReactNode;
51
+ onOpenChange?: (open: boolean) => void;
51
52
  }>;
52
53
  export declare const DropdownMenuTrigger: FC<{
53
54
  children?: ReactNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@burdenoff/microfe-assethandler",
3
- "version": "2026.1108.1",
3
+ "version": "2026.1111.1",
4
4
  "description": "Burdenoff AssetHandler Microfrontend",
5
5
  "type": "module",
6
6
  "files": [