@kahitsan/ksui 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.29.1",
3
+ "version": "0.31.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -73,6 +73,10 @@ const styles: Record<string, string> = {
73
73
 
74
74
  export type ButtonIntent = "primary" | "danger" | "secondary";
75
75
  export type ButtonVariant = "clip1" | "clip2" | "ghost" | "link";
76
+ /** "md" (default) preserves the original px-4/py-2/text-sm footprint everywhere
77
+ * it's already used. "sm" is for dense contexts — e.g. a DataTable row action
78
+ * — where the default footprint reads as oversized against compact rows. */
79
+ export type ButtonSize = "sm" | "md";
76
80
 
77
81
  export interface ButtonProps {
78
82
  /** Element / component to render as (defaults to "button"). Intentionally
@@ -83,6 +87,7 @@ export interface ButtonProps {
83
87
  as?: any;
84
88
  intent?: ButtonIntent;
85
89
  variant?: ButtonVariant;
90
+ size?: ButtonSize;
86
91
  noRipple?: boolean;
87
92
  noScanline?: boolean;
88
93
  noGlow?: boolean;
@@ -164,17 +169,17 @@ interface VariantConfig {
164
169
  const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
165
170
  clip1: {
166
171
  effects: ["clip-top-left-bottom-right"],
167
- baseClasses: "px-4 py-2 border",
172
+ baseClasses: "border",
168
173
  overrideType: false,
169
174
  },
170
175
  clip2: {
171
176
  effects: ["clip-top-right-bottom-left"],
172
- baseClasses: "px-4 py-2 border",
177
+ baseClasses: "border",
173
178
  overrideType: false,
174
179
  },
175
180
  ghost: {
176
181
  effects: [],
177
- baseClasses: "px-4 py-2 bg-transparent border-transparent hover:bg-current/10 hover:border-transparent",
182
+ baseClasses: "bg-transparent border-transparent hover:bg-current/10 hover:border-transparent",
178
183
  overrideType: true,
179
184
  },
180
185
  link: {
@@ -185,6 +190,16 @@ const buttonVariantConfig: Record<ButtonVariant, VariantConfig> = {
185
190
  },
186
191
  };
187
192
 
193
+ // Padding/text/gap live here (not in variantConfig) so a variant's padding is
194
+ // never in the same class-string as size's — `cn()` is a plain join with no
195
+ // tailwind-merge dedup, so two conflicting padding utilities would silently
196
+ // race on Tailwind's generated CSS order instead of on usage order. `link` is
197
+ // always unpadded regardless of size (see buttonVariantConfig.link).
198
+ const buttonSizeConfig: Record<ButtonSize, { padding: string; text: string; gap: string }> = {
199
+ md: { padding: "px-4 py-2", text: "text-sm", gap: "gap-2" },
200
+ sm: { padding: "px-2.5 py-1", text: "text-xs", gap: "gap-1.5" },
201
+ };
202
+
188
203
  interface Ripple {
189
204
  id: number;
190
205
  x: number;
@@ -201,6 +216,7 @@ const Button = (props: ButtonProps): JSX.Element => {
201
216
  as: "button" as ButtonProps["as"],
202
217
  intent: "primary" as ButtonIntent,
203
218
  variant: "clip1" as ButtonVariant,
219
+ size: "md" as ButtonSize,
204
220
  noRipple: false,
205
221
  noScanline: false,
206
222
  noGlow: false,
@@ -214,6 +230,7 @@ const Button = (props: ButtonProps): JSX.Element => {
214
230
  "as",
215
231
  "intent",
216
232
  "variant",
233
+ "size",
217
234
  "noRipple",
218
235
  "noScanline",
219
236
  "noGlow",
@@ -238,6 +255,7 @@ const Button = (props: ButtonProps): JSX.Element => {
238
255
  const variantConfig = createMemo(
239
256
  () => buttonVariantConfig[local.variant as ButtonVariant] || buttonVariantConfig.clip1,
240
257
  );
258
+ const sizeConfig = createMemo(() => buttonSizeConfig[local.size as ButtonSize] || buttonSizeConfig.md);
241
259
 
242
260
  const hasRipple = createMemo(() => {
243
261
  if (local.noRipple) return false;
@@ -288,15 +306,22 @@ const Button = (props: ButtonProps): JSX.Element => {
288
306
 
289
307
  const classes = createMemo(() => {
290
308
  const coreClasses =
291
- "select-none inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 focus:outline-none text-sm rounded";
309
+ "select-none inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none rounded";
292
310
  const textColor = intentConfig().textColor;
293
311
  const backgroundClasses = variantConfig().overrideType
294
312
  ? ""
295
313
  : `${intentConfig().background} ${intentConfig().hover}`;
296
314
  const variantClasses = variantConfig().baseClasses;
315
+ // link is always unpadded (baseClasses carries its own "px-0 py-0") but
316
+ // still takes text/gap sizing; every other variant takes padding too.
317
+ const sizeClasses =
318
+ local.variant === "link"
319
+ ? `${sizeConfig().text} ${sizeConfig().gap}`
320
+ : `${sizeConfig().padding} ${sizeConfig().text} ${sizeConfig().gap}`;
297
321
 
298
322
  return cn(
299
323
  coreClasses,
324
+ sizeClasses,
300
325
  textColor,
301
326
  backgroundClasses,
302
327
  variantClasses,
@@ -66,7 +66,7 @@ const STYLE_ID = "ksui-datatable-style";
66
66
  // Full list of overridable vars (default = host value):
67
67
  // --ksui-dt-card-bg outer card background
68
68
  // (host `.card-bg`: linear-gradient(135deg,#0f0f0f,#1a1a1a))
69
- // --ksui-dt-radius outer card / control corner radius (0.5rem)
69
+ // --ksui-dt-radius outer card / control corner radius (0.625rem)
70
70
  // --ksui-dt-border card / header / footer / control border (zinc-800/50, rgba(39,39,42,0.5))
71
71
  // --ksui-dt-row-border row divider + expansion-row top border (zinc-800/30, rgba(39,39,42,0.3))
72
72
  // --ksui-dt-control-bg filter button / menu / select / search / show-more bg (zinc-900, #18181b)
@@ -84,10 +84,21 @@ const STYLE_ID = "ksui-datatable-style";
84
84
  // --ksui-dt-accent active accent text: active pager num / show-more hover (amber-400, #fbbf24)
85
85
  // --ksui-dt-accent-bg active pager num bg (amber-600/20, rgba(217,119,6,0.2))
86
86
  // --ksui-dt-accent-border focus/hover accent border: search focus, show-more hover (amber-500/40, rgba(245,158,11,0.4))
87
+ // --ksui-dt-good positive-status badge text, e.g. "Active" (emerald-400, #34d399)
88
+ // --ksui-dt-good-bg positive-status badge bg (emerald-400/14, rgba(52,211,153,0.14))
89
+ // --ksui-dt-danger negative-status badge text, e.g. "Overdue"/"Expired" (red-400, #f87171)
90
+ // --ksui-dt-danger-bg negative-status badge bg (red-400/14, rgba(248,113,113,0.14))
91
+ // --ksui-dt-thead-bg header-row tint, layered over card-bg to set it apart from data rows (white/3%, rgba(255,255,255,0.03))
92
+ //
93
+ // `.ksui-datatable-td-num` / `.ksui-datatable-th-num` (mono, tabular-nums,
94
+ // right-aligned, nowrap) and `.ksui-datatable-badge` (+ `-ok` / `-warn` /
95
+ // `-danger` modifiers, using the vars above) are opt-in utility classes a
96
+ // consumer passes via a column's `className` — DataTable itself never
97
+ // infers column semantics from data.
87
98
  //
88
99
  const DATATABLE_CSS = `
89
- .ksui-datatable{background:var(--ksui-dt-card-bg,linear-gradient(135deg,#0f0f0f 0%,#1a1a1a 100%));border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));border-radius:var(--ksui-dt-radius,0.5rem);color:var(--ksui-dt-fg,#e4e4e7);}
90
- .ksui-datatable-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:1rem;}
100
+ .ksui-datatable{background:var(--ksui-dt-card-bg,linear-gradient(135deg,#0f0f0f 0%,#1a1a1a 100%));border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));border-radius:var(--ksui-dt-radius,0.625rem);color:var(--ksui-dt-fg,#e4e4e7);box-shadow:0 1px 2px rgba(0,0,0,0.3),0 8px 20px -10px rgba(0,0,0,0.5);}
101
+ .ksui-datatable-header{display:flex;flex-wrap:wrap;align-items:center;gap:0.75rem;border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.75rem 0.875rem;}
91
102
  .ksui-datatable-filters-inline{display:none;flex:1 1 0%;}
92
103
  .ksui-datatable-filters-mobile{display:block;flex:1 1 0%;}
93
104
  @media (min-width:768px){.ksui-datatable-filters-inline{display:block;}.ksui-datatable-filters-mobile{display:none;}}
@@ -105,9 +116,10 @@ const DATATABLE_CSS = `
105
116
  .ksui-datatable-search-input:focus{border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));}
106
117
  @media (min-width:640px){.ksui-datatable-search-input{width:18rem;}}
107
118
  .ksui-datatable-scroll{overflow-x:auto;transition:opacity 0.15s ease;}
108
- .ksui-datatable-table{width:100%;text-align:left;font-size:0.875rem;line-height:1.25rem;border-collapse:collapse;}
109
- .ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--ksui-dt-muted,#71717a);}
110
- .ksui-datatable-th{padding:0.75rem 1rem;}
119
+ .ksui-datatable-table{width:100%;text-align:left;font-size:0.78125rem;line-height:1.3;border-collapse:collapse;}
120
+ .ksui-datatable-thead{border-bottom:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-thead-bg,rgba(255,255,255,0.03));font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.65625rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--ksui-dt-muted,#71717a);}
121
+ .ksui-datatable-th{padding:0.5625rem 0.875rem;background:var(--ksui-dt-thead-bg,rgba(255,255,255,0.03));white-space:nowrap;}
122
+ .ksui-datatable-th-num{text-align:right;}
111
123
  .ksui-datatable-th-sortable{cursor:pointer;transition:color 0.15s ease;}
112
124
  .ksui-datatable-th-sortable:hover{color:var(--ksui-dt-text-strong,#d4d4d8);}
113
125
  .ksui-datatable-th-inner{display:inline-flex;align-items:center;}
@@ -116,13 +128,19 @@ const DATATABLE_CSS = `
116
128
  .ksui-datatable-row:hover{background-color:var(--ksui-dt-row-hover,rgba(39,39,42,0.5));}
117
129
  .ksui-datatable-row-clickable{cursor:pointer;}
118
130
  .ksui-datatable-row-clickable:active{background-color:var(--ksui-dt-row-active,rgba(39,39,42,0.7));}
119
- .ksui-datatable-td{padding:0.75rem 1rem;}
131
+ .ksui-datatable-td{padding:0.5rem 0.875rem;vertical-align:middle;white-space:nowrap;}
132
+ .ksui-datatable-td-num{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap;color:var(--ksui-dt-text-strong,#d4d4d8);}
133
+ .ksui-datatable-code{display:block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.71875rem;color:var(--ksui-dt-fg,#e4e4e7);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34ch;}
134
+ .ksui-datatable-badge{display:inline-block;font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace;font-size:0.625rem;padding:0.125rem 0.4375rem;border-radius:0.3125rem;letter-spacing:0.02em;background:var(--ksui-dt-control-bg,#18181b);color:var(--ksui-dt-text,#a1a1aa);}
135
+ .ksui-datatable-badge-ok{background:var(--ksui-dt-good-bg,rgba(52,211,153,0.14));color:var(--ksui-dt-good,#34d399);}
136
+ .ksui-datatable-badge-warn{background:var(--ksui-dt-accent-bg,rgba(217,119,6,0.2));color:var(--ksui-dt-accent,#fbbf24);}
137
+ .ksui-datatable-badge-danger{background:var(--ksui-dt-danger-bg,rgba(248,113,113,0.14));color:var(--ksui-dt-danger,#f87171);}
120
138
  .ksui-datatable-expansion-row{border-top:1px solid var(--ksui-dt-row-border,rgba(39,39,42,0.3));background-color:var(--ksui-dt-expansion-bg,rgba(9,9,11,0.4));}
121
139
  .ksui-datatable-expansion-td{padding:0;}
122
- .ksui-datatable-skeleton{height:1.25rem;width:100%;border-radius:0.25rem;background:var(--ksui-dt-skeleton,rgba(39,39,42,0.5));animation:ksuiDatatablePulse 1.5s cubic-bezier(0.4,0,0.6,1) infinite;}
140
+ .ksui-datatable-skeleton{height:1rem;width:100%;border-radius:0.25rem;background:var(--ksui-dt-skeleton,rgba(39,39,42,0.5));animation:ksuiDatatablePulse 1.5s cubic-bezier(0.4,0,0.6,1) infinite;}
123
141
  @keyframes ksuiDatatablePulse{0%,100%{opacity:1;}50%{opacity:0.5;}}
124
142
  .ksui-datatable-empty{padding:3rem 1rem;text-align:center;color:var(--ksui-dt-muted,#71717a);}
125
- .ksui-datatable-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:1rem;}
143
+ .ksui-datatable-footer{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));padding:0.625rem 0.875rem;}
126
144
  .ksui-datatable-info{font-size:0.75rem;color:var(--ksui-dt-muted,#71717a);}
127
145
  .ksui-datatable-showmore{border-radius:0.5rem;border:1px solid var(--ksui-dt-border,rgba(39,39,42,0.5));background:var(--ksui-dt-control-bg,#18181b);padding:0.5rem 1rem;font-size:0.75rem;font-weight:500;color:var(--ksui-dt-text-strong,#d4d4d8);transition:border-color 0.15s ease,color 0.15s ease;cursor:pointer;}
128
146
  .ksui-datatable-showmore:hover:not(:disabled){border-color:var(--ksui-dt-accent-border,rgba(245,158,11,0.4));color:var(--ksui-dt-accent,#fbbf24);}
@@ -1,89 +1,74 @@
1
- import { Show, type JSX } from "solid-js";
1
+ import { type JSX } from "solid-js";
2
2
 
3
3
  export type StatusTone = "success" | "neutral" | "warning" | "danger" | "info";
4
4
 
5
5
  interface ToneClass {
6
6
  text: string;
7
- /** Lighter background (default, the packages-style /10). */
7
+ /** Lighter background (default). */
8
8
  bg: string;
9
- /** Heavier background (the clients/vouchers-style /20, used when solid). */
9
+ /** Heavier background, used when `solid` is set. */
10
10
  bgSolid: string;
11
- border: string;
12
- /** Fill color for the optional leading dot. */
13
- dot: string;
14
11
  }
15
12
 
16
13
  // Module-private tone palette. The caller maps its own domain enum
17
14
  // (status / is_active / voucher state) to one of these tones and passes a
18
15
  // plain label; nothing domain-specific leaks into this atom.
16
+ //
17
+ // Flat tinted bg + text, no border — a borderless chip reads cleaner at the
18
+ // compact sizes tables now use than the previous bordered pill did.
19
19
  const TONE_CLASS: Record<StatusTone, ToneClass> = {
20
20
  success: {
21
21
  text: "text-emerald-400",
22
22
  bg: "bg-emerald-500/10",
23
23
  bgSolid: "bg-emerald-500/20",
24
- border: "border-emerald-400/40",
25
- dot: "bg-emerald-400",
26
24
  },
27
25
  neutral: {
28
26
  text: "text-zinc-400",
29
27
  bg: "bg-zinc-800/60",
30
28
  bgSolid: "bg-zinc-700/40",
31
- border: "border-zinc-600",
32
- dot: "bg-zinc-400",
33
29
  },
34
30
  warning: {
35
31
  text: "text-amber-400",
36
32
  bg: "bg-amber-500/10",
37
33
  bgSolid: "bg-amber-500/20",
38
- border: "border-amber-400/40",
39
- dot: "bg-amber-400",
40
34
  },
41
35
  danger: {
42
36
  text: "text-red-400",
43
37
  bg: "bg-red-500/10",
44
38
  bgSolid: "bg-red-500/20",
45
- border: "border-red-400/40",
46
- dot: "bg-red-400",
47
39
  },
48
40
  info: {
49
41
  text: "text-blue-400",
50
42
  bg: "bg-blue-500/10",
51
43
  bgSolid: "bg-blue-500/20",
52
- border: "border-blue-400/40",
53
- dot: "bg-blue-400",
54
44
  },
55
45
  };
56
46
 
57
47
  interface StatusPillProps {
58
- /** Text shown inside the pill (the caller's own label). */
48
+ /** Text shown inside the pill (the caller's own label), rendered as-is —
49
+ * the caller decides case. */
59
50
  label: string;
60
51
  /** Domain-free tone selector. The caller maps its enum to one of these. */
61
52
  tone: StatusTone;
62
- /** Render a leading filled dot in the tone's dot color. */
63
- dot?: boolean;
64
- /** Heavier /20 background (clients/vouchers style); default is the
65
- * lighter /10 background (packages style). */
53
+ /** Heavier /20 background; default is the lighter /10 background. */
66
54
  solid?: boolean;
67
55
  /** Extra classes on the pill wrapper. */
68
56
  class?: string;
69
57
  testId?: string;
70
58
  }
71
59
 
72
- // Single status-pill atom. Baseline is the uppercase, wide-tracked,
73
- // text-[10px] chip from the packages StatusPill. The optional dot covers the
74
- // clients is_active pill, and solid covers the heavier clients/vouchers
75
- // backgrounds. Domain enum -> tone + label mapping stays with the caller.
60
+ // Single status-pill atom: a flat tinted-bg text chip, no border, no leading
61
+ // dot, no forced case — the caller's label renders exactly as given. `solid`
62
+ // switches to the heavier background. Domain enum -> tone + label mapping
63
+ // stays with the caller.
76
64
  export default function StatusPill(props: StatusPillProps): JSX.Element {
77
65
  const tc = () => TONE_CLASS[props.tone];
78
66
  const bg = () => (props.solid ? tc().bgSolid : tc().bg);
79
67
  return (
80
68
  <span
81
69
  data-testid={props.testId}
82
- class={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border uppercase tracking-wider ${tc().text} ${tc().border} ${bg()} ${props.class ?? ""}`}
70
+ class={`inline-flex items-center text-xs px-1.5 py-0.5 rounded ${tc().text} ${bg()} ${props.class ?? ""}`}
83
71
  >
84
- <Show when={props.dot}>
85
- <span class={`h-1.5 w-1.5 rounded-full ${tc().dot}`} aria-hidden="true" />
86
- </Show>
87
72
  {props.label}
88
73
  </span>
89
74
  );
@@ -31,7 +31,7 @@ export function renderCell(
31
31
  case "status": {
32
32
  const active = Boolean(raw);
33
33
  const arm = active ? r.active : r.inactive;
34
- return <StatusPill label={arm.label} tone={arm.tone} dot solid />;
34
+ return <StatusPill label={arm.label} tone={arm.tone} solid />;
35
35
  }
36
36
  case "text":
37
37
  default: {
package/src/index.ts CHANGED
@@ -200,8 +200,9 @@ export type { ObjectUrlOptions } from "./utils/object-url-resource";
200
200
  export {
201
201
  createPendingFile,
202
202
  revokePendingFile,
203
+ uploadPendingFiles,
203
204
  } from "./utils/pending-file";
204
- export type { PendingFile } from "./utils/pending-file";
205
+ export type { PendingFile, UploadPendingFilesOptions } from "./utils/pending-file";
205
206
 
206
207
  export { useAccountsIndex, resolveAccount, resolveAccountName } from "./utils/accounts-index";
207
208
 
@@ -0,0 +1,59 @@
1
+ // uploadPendingFiles: per-file best-effort POST to the transactions plugin's
2
+ // standard attachment route, returning the names that failed.
3
+ import { describe, expect, it, vi, afterEach } from "vitest";
4
+ import { uploadPendingFiles, type PendingFile } from "./pending-file";
5
+
6
+ function pf(name: string): PendingFile {
7
+ return { id: name, file: new File(["x"], name), previewUrl: null };
8
+ }
9
+
10
+ describe("uploadPendingFiles", () => {
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ it("POSTs each file to the transaction's attachments route with credentials included", async () => {
16
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
17
+ vi.stubGlobal("fetch", fetchMock);
18
+
19
+ const failed = await uploadPendingFiles(42, [pf("receipt.jpg")]);
20
+
21
+ expect(failed).toEqual([]);
22
+ expect(fetchMock).toHaveBeenCalledWith(
23
+ "/api/transactions/42/attachments",
24
+ expect.objectContaining({ method: "POST", credentials: "include" })
25
+ );
26
+ });
27
+
28
+ it("layers caller-supplied headers onto the request", async () => {
29
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true });
30
+ vi.stubGlobal("fetch", fetchMock);
31
+
32
+ await uploadPendingFiles(42, [pf("receipt.jpg")], {
33
+ headers: { "X-Workspace-Id": "7" },
34
+ });
35
+
36
+ expect(fetchMock).toHaveBeenCalledWith(
37
+ "/api/transactions/42/attachments",
38
+ expect.objectContaining({ headers: { "X-Workspace-Id": "7" } })
39
+ );
40
+ });
41
+
42
+ it("collects failed file names without aborting the remaining uploads", async () => {
43
+ const fetchMock = vi
44
+ .fn()
45
+ .mockResolvedValueOnce({ ok: false })
46
+ .mockRejectedValueOnce(new Error("network error"))
47
+ .mockResolvedValueOnce({ ok: true });
48
+ vi.stubGlobal("fetch", fetchMock);
49
+
50
+ const failed = await uploadPendingFiles(42, [
51
+ pf("a.jpg"),
52
+ pf("b.jpg"),
53
+ pf("c.jpg"),
54
+ ]);
55
+
56
+ expect(failed).toEqual(["a.jpg", "b.jpg"]);
57
+ expect(fetchMock).toHaveBeenCalledTimes(3);
58
+ });
59
+ });
@@ -1,7 +1,7 @@
1
1
  // Pre-upload pending file state — a file the user has picked or pasted but not
2
- // yet uploaded. The two plugins that support attachment upload (transactions,
3
- // timesheets/payroll) both defined this locally; extracted here so they share
4
- // one canonical type + helpers and avoid drift.
2
+ // yet uploaded. The plugins that support attachment upload (transactions,
3
+ // counter, timesheets/payroll) each defined this locally; extracted here so
4
+ // they share one canonical type + helpers and avoid drift.
5
5
 
6
6
  /** A file the user picked or pasted but hasn't been uploaded yet. */
7
7
  export interface PendingFile {
@@ -29,3 +29,40 @@ export function createPendingFile(file: File): PendingFile {
29
29
  export function revokePendingFile(pf: PendingFile): void {
30
30
  if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl);
31
31
  }
32
+
33
+ /** Extra fetch() init the caller needs layered onto every upload request —
34
+ * e.g. timesheets/payroll's X-Workspace-Id header for the fresh-login case
35
+ * where the host's fetch monkey-patch has no localStorage fallback yet. */
36
+ export interface UploadPendingFilesOptions {
37
+ headers?: Record<string, string>;
38
+ }
39
+
40
+ /** Upload each pending file to the transactions plugin's standard multipart
41
+ * POST /:id/attachments S3 route — the one place attachment bytes are ever
42
+ * written, whichever plugin's UI collected them. Best-effort per file: a
43
+ * failed upload doesn't stop the rest. Returns the file names that failed,
44
+ * in the file's own `name` (not a caller-relabeled `file_name`), for the
45
+ * caller to surface as a soft error. */
46
+ export async function uploadPendingFiles(
47
+ transactionId: number,
48
+ files: PendingFile[],
49
+ opts: UploadPendingFilesOptions = {}
50
+ ): Promise<string[]> {
51
+ const failed: string[] = [];
52
+ for (const pf of files) {
53
+ try {
54
+ const fd = new FormData();
55
+ fd.append("file", pf.file, pf.file.name);
56
+ const res = await fetch(`/api/transactions/${transactionId}/attachments`, {
57
+ method: "POST",
58
+ credentials: "include",
59
+ headers: opts.headers,
60
+ body: fd,
61
+ });
62
+ if (!res.ok) failed.push(pf.file.name);
63
+ } catch {
64
+ failed.push(pf.file.name);
65
+ }
66
+ }
67
+ return failed;
68
+ }