@svadmin/ui 0.63.0 → 0.65.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.
Files changed (104) hide show
  1. package/dist/components/ActivityFeed.svelte +135 -0
  2. package/dist/components/ActivityFeed.svelte.d.ts +21 -0
  3. package/dist/components/ApprovalActionCard.svelte +248 -0
  4. package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
  5. package/dist/components/AutoTable.svelte +8 -0
  6. package/dist/components/AutoTable.svelte.d.ts +7 -1
  7. package/dist/components/CanvasAnnotation.svelte +389 -0
  8. package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
  9. package/dist/components/Cascader.svelte +205 -0
  10. package/dist/components/Cascader.svelte.d.ts +21 -0
  11. package/dist/components/ColumnHeaderFilter.svelte +178 -0
  12. package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
  13. package/dist/components/ColumnSettings.svelte +198 -0
  14. package/dist/components/ColumnSettings.svelte.d.ts +19 -0
  15. package/dist/components/DecisionTable.svelte +240 -0
  16. package/dist/components/DecisionTable.svelte.d.ts +22 -0
  17. package/dist/components/DraggableRowTable.svelte +117 -0
  18. package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
  19. package/dist/components/DrawerForm.svelte +110 -26
  20. package/dist/components/DrawerForm.svelte.d.ts +20 -8
  21. package/dist/components/DynamicFormList.svelte +214 -0
  22. package/dist/components/DynamicFormList.svelte.d.ts +62 -0
  23. package/dist/components/EditableTable.svelte +195 -0
  24. package/dist/components/EditableTable.svelte.d.ts +25 -0
  25. package/dist/components/FieldRenderer.svelte +32 -0
  26. package/dist/components/FilterBuilder.svelte +310 -0
  27. package/dist/components/FilterBuilder.svelte.d.ts +23 -0
  28. package/dist/components/GanttChart.svelte +144 -0
  29. package/dist/components/GanttChart.svelte.d.ts +20 -0
  30. package/dist/components/ImageCropper.svelte +122 -0
  31. package/dist/components/ImageCropper.svelte.d.ts +12 -0
  32. package/dist/components/ImportWizard.svelte +336 -0
  33. package/dist/components/ImportWizard.svelte.d.ts +12 -0
  34. package/dist/components/JsonSchemaForm.svelte +149 -0
  35. package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
  36. package/dist/components/KanbanBoard.svelte +212 -0
  37. package/dist/components/KanbanBoard.svelte.d.ts +30 -0
  38. package/dist/components/MasterDetailView.svelte +134 -0
  39. package/dist/components/MasterDetailView.svelte.d.ts +16 -0
  40. package/dist/components/MediaLibraryModal.svelte +195 -0
  41. package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
  42. package/dist/components/MentionsInput.svelte +174 -0
  43. package/dist/components/MentionsInput.svelte.d.ts +21 -0
  44. package/dist/components/ModalForm.svelte +113 -28
  45. package/dist/components/ModalForm.svelte.d.ts +20 -8
  46. package/dist/components/MultiTabKeepAlive.svelte +143 -0
  47. package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
  48. package/dist/components/OfflineSyncBanner.svelte +172 -0
  49. package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
  50. package/dist/components/PdfDocumentViewer.svelte +255 -0
  51. package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
  52. package/dist/components/PivotTable.svelte +140 -0
  53. package/dist/components/PivotTable.svelte.d.ts +16 -0
  54. package/dist/components/PresenceAvatarGroup.svelte +89 -0
  55. package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
  56. package/dist/components/PrintableBill.svelte +149 -0
  57. package/dist/components/PrintableBill.svelte.d.ts +24 -0
  58. package/dist/components/SensitiveDataMask.svelte +125 -0
  59. package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
  60. package/dist/components/SignaturePad.svelte +264 -0
  61. package/dist/components/SignaturePad.svelte.d.ts +17 -0
  62. package/dist/components/SplitPaneLayout.svelte +113 -0
  63. package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
  64. package/dist/components/SpreadsheetView.svelte +363 -0
  65. package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
  66. package/dist/components/StepForm.svelte +180 -0
  67. package/dist/components/StepForm.svelte.d.ts +23 -0
  68. package/dist/components/TableSummary.svelte +110 -0
  69. package/dist/components/TableSummary.svelte.d.ts +20 -0
  70. package/dist/components/Transfer.svelte +352 -0
  71. package/dist/components/Transfer.svelte.d.ts +28 -0
  72. package/dist/components/TreeSelect.svelte +307 -0
  73. package/dist/components/TreeSelect.svelte.d.ts +22 -0
  74. package/dist/components/TreeTable.svelte +237 -0
  75. package/dist/components/TreeTable.svelte.d.ts +28 -0
  76. package/dist/components/UnsavedChangesPrompt.svelte +44 -0
  77. package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
  78. package/dist/components/VersionDiffViewer.svelte +196 -0
  79. package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
  80. package/dist/components/VirtualTable.svelte +86 -54
  81. package/dist/components/VirtualTable.svelte.d.ts +14 -21
  82. package/dist/components/Watermark.svelte +166 -0
  83. package/dist/components/Watermark.svelte.d.ts +26 -0
  84. package/dist/components/ui/navigation-menu/index.d.ts +11 -0
  85. package/dist/components/ui/navigation-menu/index.js +13 -0
  86. package/dist/components/ui/navigation-menu/navigation-menu-content.svelte +21 -0
  87. package/dist/components/ui/navigation-menu/navigation-menu-content.svelte.d.ts +4 -0
  88. package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte +22 -0
  89. package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte.d.ts +4 -0
  90. package/dist/components/ui/navigation-menu/navigation-menu-item.svelte +17 -0
  91. package/dist/components/ui/navigation-menu/navigation-menu-item.svelte.d.ts +4 -0
  92. package/dist/components/ui/navigation-menu/navigation-menu-link.svelte +20 -0
  93. package/dist/components/ui/navigation-menu/navigation-menu-link.svelte.d.ts +4 -0
  94. package/dist/components/ui/navigation-menu/navigation-menu-list.svelte +17 -0
  95. package/dist/components/ui/navigation-menu/navigation-menu-list.svelte.d.ts +4 -0
  96. package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte +28 -0
  97. package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte.d.ts +4 -0
  98. package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte +22 -0
  99. package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte.d.ts +4 -0
  100. package/dist/components/ui/navigation-menu/navigation-menu.svelte +24 -0
  101. package/dist/components/ui/navigation-menu/navigation-menu.svelte.d.ts +4 -0
  102. package/dist/index.d.ts +68 -3
  103. package/dist/index.js +42 -3
  104. package/package.json +2 -2
@@ -0,0 +1,144 @@
1
+ <script lang="ts">
2
+ import { Badge } from './ui/badge/index.js';
3
+ import { cn } from '../utils.js';
4
+
5
+ export interface GanttTask {
6
+ id: string;
7
+ title: string;
8
+ startDay: number; // 0-based offset
9
+ durationDays: number;
10
+ progress?: number; // 0 to 100
11
+ category?: string;
12
+ assignee?: string;
13
+ status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
14
+ }
15
+
16
+ interface Props {
17
+ tasks: GanttTask[];
18
+ totalDays?: number;
19
+ dayLabelPrefix?: string;
20
+ onselecttask?: (task: GanttTask) => void;
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ tasks = [],
26
+ totalDays = 14,
27
+ dayLabelPrefix = 'D',
28
+ onselecttask,
29
+ class: className = '',
30
+ }: Props = $props();
31
+
32
+ const days = $derived(
33
+ Array.from({ length: totalDays }, (_, i) => `${dayLabelPrefix}${i + 1}`)
34
+ );
35
+
36
+ function getStatusBadgeVariant(status?: string) {
37
+ switch (status) {
38
+ case 'completed':
39
+ return 'bg-success/15 text-success border-success/30';
40
+ case 'delayed':
41
+ return 'bg-destructive/15 text-destructive border-destructive/30';
42
+ case 'in_progress':
43
+ return 'bg-primary/15 text-primary border-primary/30';
44
+ default:
45
+ return 'bg-muted text-muted-foreground border-border/40';
46
+ }
47
+ }
48
+
49
+ function getBarColor(status?: string) {
50
+ switch (status) {
51
+ case 'completed':
52
+ return 'bg-success text-success-foreground';
53
+ case 'delayed':
54
+ return 'bg-destructive text-destructive-foreground';
55
+ default:
56
+ return 'bg-primary text-primary-foreground';
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <div class={cn('rounded-xl border border-border bg-card p-4 shadow-xs text-xs space-y-3', className)}>
62
+ <div class="flex items-center justify-between pb-2 border-b border-border/60">
63
+ <div class="font-semibold text-foreground">
64
+ Project Gantt Schedule <span class="text-muted-foreground font-normal">({tasks.length} tasks / {totalDays} days)</span>
65
+ </div>
66
+ </div>
67
+
68
+ <div class="overflow-x-auto rounded-lg border border-border/60">
69
+ <div class="min-w-[700px]">
70
+ <!-- Timeline Header -->
71
+ <div class="flex border-b border-border/60 bg-muted/40 font-semibold text-muted-foreground">
72
+ <div class="w-56 p-2.5 border-r border-border/60 text-left shrink-0">Task Name</div>
73
+ <div class="flex-1 flex">
74
+ {#each days as day (day)}
75
+ <div class="flex-1 p-2 text-center border-r border-border/40 text-[11px] font-mono last:border-r-0">
76
+ {day}
77
+ </div>
78
+ {/each}
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Task Rows -->
83
+ <div class="divide-y divide-border/40">
84
+ {#each tasks as task (task.id)}
85
+ {@const leftPct = (task.startDay / totalDays) * 100}
86
+ {@const widthPct = (task.durationDays / totalDays) * 100}
87
+ <div
88
+ role="button"
89
+ tabindex="0"
90
+ class="flex items-center hover:bg-muted/20 transition-colors cursor-pointer"
91
+ onclick={() => onselecttask?.(task)}
92
+ onkeydown={(e) => { if (e.key === 'Enter') onselecttask?.(task); }}
93
+ >
94
+ <!-- Task Info Column -->
95
+ <div class="w-56 p-2.5 border-r border-border/60 flex items-center justify-between gap-2 shrink-0">
96
+ <div class="truncate font-medium text-foreground">{task.title}</div>
97
+ {#if task.status}
98
+ <Badge variant="outline" class={cn('text-[9px] px-1 py-0 uppercase', getStatusBadgeVariant(task.status))}>
99
+ {task.status}
100
+ </Badge>
101
+ {/if}
102
+ </div>
103
+
104
+ <!-- Task Bar Grid Area -->
105
+ <div class="flex-1 relative h-10 flex items-center px-1">
106
+ <!-- Grid vertical guideline background -->
107
+ <div class="absolute inset-0 flex pointer-events-none">
108
+ {#each days as _, idx (idx)}
109
+ <div class="flex-1 border-r border-border/20 last:border-r-0"></div>
110
+ {/each}
111
+ </div>
112
+
113
+ <!-- Bar element -->
114
+ <div
115
+ class={cn(
116
+ 'relative h-6 rounded-md shadow-xs flex items-center justify-between px-2 text-[10px] font-medium overflow-hidden transition-all group hover:brightness-110',
117
+ getBarColor(task.status)
118
+ )}
119
+ style={`margin-left: ${leftPct}%; width: ${Math.max(widthPct, 4)}%;`}
120
+ >
121
+ <!-- Inner progress fill -->
122
+ {#if task.progress !== undefined}
123
+ <div
124
+ class="absolute inset-y-0 left-0 bg-foreground/20 pointer-events-none"
125
+ style={`width: ${task.progress}%;`}
126
+ ></div>
127
+ {/if}
128
+
129
+ <span class="truncate relative z-10">{task.title}</span>
130
+ {#if task.progress !== undefined}
131
+ <span class="relative z-10 text-[9px] opacity-90 font-mono">{task.progress}%</span>
132
+ {/if}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ {/each}
137
+
138
+ {#if tasks.length === 0}
139
+ <div class="py-8 text-center text-muted-foreground">No schedule tasks available</div>
140
+ {/if}
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
@@ -0,0 +1,20 @@
1
+ export interface GanttTask {
2
+ id: string;
3
+ title: string;
4
+ startDay: number;
5
+ durationDays: number;
6
+ progress?: number;
7
+ category?: string;
8
+ assignee?: string;
9
+ status?: 'planned' | 'in_progress' | 'completed' | 'delayed';
10
+ }
11
+ interface Props {
12
+ tasks: GanttTask[];
13
+ totalDays?: number;
14
+ dayLabelPrefix?: string;
15
+ onselecttask?: (task: GanttTask) => void;
16
+ class?: string;
17
+ }
18
+ declare const GanttChart: import("svelte").Component<Props, {}, "">;
19
+ type GanttChart = ReturnType<typeof GanttChart>;
20
+ export default GanttChart;
@@ -0,0 +1,122 @@
1
+ <script lang="ts">
2
+ import { Button } from './ui/button/index.js';
3
+ import { RotateCw, ZoomIn, ZoomOut, Crop } from '@lucide/svelte';
4
+ import { cn } from '../utils.js';
5
+
6
+ interface Props {
7
+ imageUrl: string;
8
+ aspectRatio?: number;
9
+ zoom?: number;
10
+ rotation?: number;
11
+ oncrop?: (croppedDataUrl: string) => void;
12
+ oncancel?: () => void;
13
+ class?: string;
14
+ }
15
+
16
+ let {
17
+ imageUrl,
18
+ aspectRatio = 1,
19
+ zoom = $bindable(1),
20
+ rotation = $bindable(0),
21
+ oncrop,
22
+ oncancel,
23
+ class: className = '',
24
+ }: Props = $props();
25
+
26
+ let canvasEl = $state<HTMLCanvasElement | null>(null);
27
+
28
+ function handleRotate() {
29
+ rotation = (rotation + 90) % 360;
30
+ }
31
+
32
+ function handleCrop() {
33
+ if (!canvasEl) return;
34
+ const img = new Image();
35
+ img.crossOrigin = 'anonymous';
36
+ img.onload = () => {
37
+ const ctx = canvasEl?.getContext('2d');
38
+ if (!ctx || !canvasEl) return;
39
+
40
+ const size = 300;
41
+ canvasEl.width = size;
42
+ canvasEl.height = size / aspectRatio;
43
+
44
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
45
+ ctx.save();
46
+ ctx.translate(canvasEl.width / 2, canvasEl.height / 2);
47
+ ctx.rotate((rotation * Math.PI) / 180);
48
+ ctx.scale(zoom, zoom);
49
+ ctx.drawImage(img, -size / 2, (-size / 2) / aspectRatio, size, size / aspectRatio);
50
+ ctx.restore();
51
+
52
+ const dataUrl = canvasEl.toDataURL('image/png');
53
+ oncrop?.(dataUrl);
54
+ };
55
+ img.src = imageUrl;
56
+ }
57
+ </script>
58
+
59
+ <div class={cn('rounded-xl border border-border bg-card p-4 shadow-xs space-y-4 text-xs', className)}>
60
+ <div class="flex items-center justify-between pb-2 border-b border-border/60">
61
+ <h4 class="font-semibold text-foreground flex items-center gap-1.5">
62
+ <Crop class="h-4 w-4 text-primary" />
63
+ Image Cropper
64
+ </h4>
65
+ </div>
66
+
67
+ <!-- Crop Viewport -->
68
+ <div class="relative flex items-center justify-center h-64 w-full rounded-lg bg-muted/40 border border-border/60 overflow-hidden select-none">
69
+ <div
70
+ style="transform: scale({zoom}) rotate({rotation}deg); transition: transform 150ms ease;"
71
+ class="max-h-56 max-w-56 flex items-center justify-center"
72
+ >
73
+ <img src={imageUrl} alt="Crop Preview" class="object-contain max-h-56 max-w-56 rounded pointer-events-none" />
74
+ </div>
75
+
76
+ <!-- Aspect Ratio Overlay Grid -->
77
+ <div
78
+ style="aspect-ratio: {aspectRatio};"
79
+ class="absolute w-48 border-2 border-primary/80 rounded pointer-events-none shadow-[0_0_0_9999px_rgba(0,0,0,0.45)]"
80
+ ></div>
81
+ </div>
82
+
83
+ <!-- Controls Bar -->
84
+ <div class="flex items-center justify-between gap-3 pt-2">
85
+ <div class="flex items-center gap-2">
86
+ <Button
87
+ variant="outline"
88
+ size="sm"
89
+ class="h-7 text-xs gap-1"
90
+ onclick={() => { zoom = Math.max(0.5, Number((zoom - 0.1).toFixed(1))); }}
91
+ >
92
+ <ZoomOut class="h-3 w-3" />
93
+ </Button>
94
+ <span class="tabular-nums font-mono text-[11px] text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
95
+ <Button
96
+ variant="outline"
97
+ size="sm"
98
+ class="h-7 text-xs gap-1"
99
+ onclick={() => { zoom = Math.min(3, Number((zoom + 0.1).toFixed(1))); }}
100
+ >
101
+ <ZoomIn class="h-3 w-3" />
102
+ </Button>
103
+
104
+ <Button variant="outline" size="sm" class="h-7 text-xs gap-1 ml-2" onclick={handleRotate}>
105
+ <RotateCw class="h-3 w-3" />
106
+ Rotate
107
+ </Button>
108
+ </div>
109
+
110
+ <div class="flex items-center gap-2">
111
+ {#if oncancel}
112
+ <Button variant="outline" size="sm" class="h-7 text-xs" onclick={oncancel}>Cancel</Button>
113
+ {/if}
114
+ <Button size="sm" class="h-7 text-xs gap-1" onclick={handleCrop}>
115
+ Apply Crop
116
+ </Button>
117
+ </div>
118
+ </div>
119
+
120
+ <!-- Hidden Working Canvas -->
121
+ <canvas bind:this={canvasEl} class="hidden"></canvas>
122
+ </div>
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ imageUrl: string;
3
+ aspectRatio?: number;
4
+ zoom?: number;
5
+ rotation?: number;
6
+ oncrop?: (croppedDataUrl: string) => void;
7
+ oncancel?: () => void;
8
+ class?: string;
9
+ }
10
+ declare const ImageCropper: import("svelte").Component<Props, {}, "zoom" | "rotation">;
11
+ type ImageCropper = ReturnType<typeof ImageCropper>;
12
+ export default ImageCropper;
@@ -0,0 +1,336 @@
1
+ <script lang="ts">
2
+ import { getResource, captureAdminContext } from '@svadmin/core';
3
+ import { useTranslation } from '@svadmin/core/i18n';
4
+ import { parseCSV, toCsv } from '@svadmin/core';
5
+ import * as Dialog from './ui/dialog/index.js';
6
+ import { Button } from './ui/button/index.js';
7
+ import { Progress } from './ui/progress/index.js';
8
+ import { Badge } from './ui/badge/index.js';
9
+ import { Upload, ArrowRight, ArrowLeft, AlertCircle, Download, FileSpreadsheet, Loader2 } from '@lucide/svelte';
10
+
11
+ interface Props {
12
+ resourceName: string;
13
+ open?: boolean;
14
+ batchSize?: number;
15
+ onSuccess?: (result: { succeeded: number; failed: number }) => void;
16
+ }
17
+
18
+ let {
19
+ resourceName,
20
+ open = $bindable(false),
21
+ batchSize = 20,
22
+ onSuccess,
23
+ }: Props = $props();
24
+
25
+ const i18n = useTranslation();
26
+ const adminContext = captureAdminContext();
27
+ const resource = $derived(getResource(resourceName));
28
+ const availableFields = $derived(
29
+ resource.fields.filter((f) => f.key !== (resource.primaryKey ?? 'id') && f.showInForm !== false)
30
+ );
31
+
32
+ let currentStep = $state<1 | 2 | 3>(1);
33
+ let fileName = $state('');
34
+ let rawHeaders = $state<string[]>([]);
35
+ let rawRows = $state<string[][]>([]);
36
+ let columnMapping = $state<Record<string, string>>({}); // header -> fieldKey or ''
37
+
38
+ let isImporting = $state(false);
39
+ let processedCount = $state(0);
40
+ let succeededCount = $state(0);
41
+ let failedRecords = $state<Array<{ row: Record<string, unknown>; error: string }>>([]);
42
+
43
+ const progressPercent = $derived(
44
+ rawRows.length > 0 ? Math.min(100, Math.round((processedCount / rawRows.length) * 100)) : 0
45
+ );
46
+
47
+ async function handleFileSelect(selectedFile: File) {
48
+ fileName = selectedFile.name;
49
+ const text = await selectedFile.text();
50
+ const cleanText = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
51
+
52
+ if (fileName.endsWith('.json')) {
53
+ try {
54
+ const json = JSON.parse(cleanText);
55
+ if (Array.isArray(json) && json.length > 0) {
56
+ const keys = Object.keys(json[0]);
57
+ rawHeaders = keys;
58
+ rawRows = json.map((item) => keys.map((k) => String(item[k] ?? '')));
59
+ }
60
+ } catch {
61
+ /* invalid json */
62
+ }
63
+ } else {
64
+ const rows = parseCSV(cleanText);
65
+ if (rows.length > 0) {
66
+ rawHeaders = rows[0];
67
+ rawRows = rows.slice(1).filter((r: string[]) => r.some((cell: string) => cell.trim().length > 0));
68
+ }
69
+ }
70
+
71
+ // Auto-match headers to fields by key or label
72
+ const initialMapping: Record<string, string> = {};
73
+ for (const h of rawHeaders) {
74
+ const normalized = h.toLowerCase().trim().replace(/[-_]/g, '');
75
+ const match = availableFields.find(
76
+ (f) =>
77
+ f.key.toLowerCase().replace(/[-_]/g, '') === normalized ||
78
+ f.label.toLowerCase().replace(/[-_]/g, '') === normalized
79
+ );
80
+ initialMapping[h] = match ? match.key : '';
81
+ }
82
+ columnMapping = initialMapping;
83
+ currentStep = 2;
84
+ }
85
+
86
+ function handleDrop(e: DragEvent) {
87
+ e.preventDefault();
88
+ if (e.dataTransfer?.files?.[0]) {
89
+ handleFileSelect(e.dataTransfer.files[0]);
90
+ }
91
+ }
92
+
93
+ async function startImport() {
94
+ currentStep = 3;
95
+ isImporting = true;
96
+ processedCount = 0;
97
+ succeededCount = 0;
98
+ failedRecords = [];
99
+
100
+ const provider = adminContext.getDataProviderForResource(resourceName);
101
+
102
+ // Build mapped records
103
+ const recordsToImport: Record<string, unknown>[] = [];
104
+ for (const row of rawRows) {
105
+ const record: Record<string, unknown> = {};
106
+ rawHeaders.forEach((header, idx) => {
107
+ const targetFieldKey = columnMapping[header];
108
+ if (targetFieldKey) {
109
+ const fieldDef = availableFields.find((f) => f.key === targetFieldKey);
110
+ let val: unknown = row[idx] ?? '';
111
+ if (fieldDef?.type === 'number') {
112
+ const num = Number(val);
113
+ val = isNaN(num) ? val : num;
114
+ } else if (fieldDef?.type === 'boolean') {
115
+ val = val === 'true' || val === '1' || val === 'yes' || val === '是';
116
+ }
117
+ record[targetFieldKey] = val;
118
+ }
119
+ });
120
+ recordsToImport.push(record);
121
+ }
122
+
123
+ // Execute in batches
124
+ for (let i = 0; i < recordsToImport.length; i += batchSize) {
125
+ const batch = recordsToImport.slice(i, i + batchSize);
126
+ if (provider.createMany) {
127
+ try {
128
+ await provider.createMany({ resource: resourceName, variables: batch });
129
+ succeededCount += batch.length;
130
+ } catch (err) {
131
+ batch.forEach((item) => {
132
+ failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
133
+ });
134
+ }
135
+ } else {
136
+ for (const item of batch) {
137
+ try {
138
+ await provider.create({ resource: resourceName, variables: item });
139
+ succeededCount++;
140
+ } catch (err) {
141
+ failedRecords.push({ row: item, error: err instanceof Error ? err.message : String(err) });
142
+ }
143
+ }
144
+ }
145
+ processedCount = Math.min(recordsToImport.length, i + batch.length);
146
+ }
147
+
148
+ isImporting = false;
149
+ onSuccess?.({ succeeded: succeededCount, failed: failedRecords.length });
150
+ }
151
+
152
+ function downloadErrors() {
153
+ if (failedRecords.length === 0 || typeof document === 'undefined') return;
154
+ const errorRows = failedRecords.map((f) => ({
155
+ ...f.row,
156
+ _error_reason: f.error,
157
+ }));
158
+ const csvContent = toCsv(errorRows);
159
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
160
+ const url = URL.createObjectURL(blob);
161
+ const link = document.createElement('a');
162
+ link.href = url;
163
+ link.download = `${resourceName}-import-errors.csv`;
164
+ link.click();
165
+ URL.revokeObjectURL(url);
166
+ }
167
+
168
+ function reset() {
169
+ currentStep = 1;
170
+ fileName = '';
171
+ rawHeaders = [];
172
+ rawRows = [];
173
+ columnMapping = {};
174
+ processedCount = 0;
175
+ succeededCount = 0;
176
+ failedRecords = [];
177
+ isImporting = false;
178
+ }
179
+ </script>
180
+
181
+ {#if open}
182
+ <Dialog.Dialog bind:open>
183
+ <Dialog.DialogContent class="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
184
+ <Dialog.DialogHeader>
185
+ <Dialog.DialogTitle class="flex items-center gap-2">
186
+ <FileSpreadsheet class="h-5 w-5 text-primary" />
187
+ <span>{i18n.t('common.import', { defaultValue: 'Import' })} {resource.label}</span>
188
+ </Dialog.DialogTitle>
189
+ </Dialog.DialogHeader>
190
+
191
+ <!-- Step Indicator -->
192
+ <div class="flex items-center justify-between text-xs font-medium text-muted-foreground pb-2 border-b border-border/50">
193
+ <span class={currentStep === 1 ? 'text-primary font-semibold' : ''}>1. {i18n.t('common.uploadFile', { defaultValue: 'Upload File' })}</span>
194
+ <span>→</span>
195
+ <span class={currentStep === 2 ? 'text-primary font-semibold' : ''}>2. {i18n.t('common.mapColumns', { defaultValue: 'Map Columns' })}</span>
196
+ <span>→</span>
197
+ <span class={currentStep === 3 ? 'text-primary font-semibold' : ''}>3. {i18n.t('common.importProgress', { defaultValue: 'Import & Verify' })}</span>
198
+ </div>
199
+
200
+ <!-- Step 1: Upload -->
201
+ {#if currentStep === 1}
202
+ <div
203
+ class="border-2 border-dashed border-border/80 hover:border-primary/60 rounded-xl p-8 text-center transition-colors cursor-pointer bg-muted/20"
204
+ ondragover={(e) => e.preventDefault()}
205
+ ondrop={handleDrop}
206
+ role="region"
207
+ aria-label="Upload Dropzone"
208
+ >
209
+ <Upload class="h-10 w-10 mx-auto text-muted-foreground/60 mb-3" />
210
+ <p class="text-sm font-medium text-foreground mb-1">
211
+ {i18n.t('common.dropFileHere', { defaultValue: 'Drag and drop CSV or JSON file here' })}
212
+ </p>
213
+ <p class="text-xs text-muted-foreground mb-4">
214
+ {i18n.t('common.fileFormatHint', { defaultValue: 'Supports .csv, .json with UTF-8 encoding' })}
215
+ </p>
216
+ <label class="inline-flex">
217
+ <input
218
+ type="file"
219
+ accept=".csv,.json"
220
+ class="hidden"
221
+ onchange={(e) => {
222
+ const target = e.currentTarget;
223
+ if (target.files?.[0]) handleFileSelect(target.files[0]);
224
+ }}
225
+ />
226
+ <span class="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-xs font-medium text-primary-foreground shadow hover:bg-primary/90 cursor-pointer">
227
+ {i18n.t('common.selectFile', { defaultValue: 'Browse File' })}
228
+ </span>
229
+ </label>
230
+ </div>
231
+ {/if}
232
+
233
+ <!-- Step 2: Column Mapping -->
234
+ {#if currentStep === 2}
235
+ <div class="space-y-4">
236
+ <div class="flex items-center justify-between text-xs text-muted-foreground bg-muted/30 p-2.5 rounded-lg border border-border/60">
237
+ <span>{i18n.t('common.detectedRows', { defaultValue: 'Detected' })}: <strong class="text-foreground">{rawRows.length}</strong> {i18n.t('common.records', { defaultValue: 'records' })}</span>
238
+ <Badge variant="secondary" class="text-[11px] font-mono">{fileName}</Badge>
239
+ </div>
240
+
241
+ <div class="max-h-72 overflow-y-auto space-y-2 pr-1 border border-border/40 rounded-lg p-2">
242
+ {#each rawHeaders as header (header)}
243
+ <div class="flex items-center justify-between gap-3 p-2 rounded-md bg-card border border-border/40 text-xs">
244
+ <div class="flex-1 truncate">
245
+ <span class="font-medium text-foreground">{header}</span>
246
+ {#if rawRows[0]}
247
+ <span class="block text-[11px] text-muted-foreground truncate opacity-70">
248
+ Sample: {rawRows[0][rawHeaders.indexOf(header)] ?? '—'}
249
+ </span>
250
+ {/if}
251
+ </div>
252
+ <div class="w-48 shrink-0">
253
+ <select
254
+ class="h-8 w-full rounded-md border border-input bg-background px-2 text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
255
+ bind:value={columnMapping[header]}
256
+ >
257
+ <option value="">— {i18n.t('common.ignoreColumn', { defaultValue: 'Ignore' })} —</option>
258
+ {#each availableFields as field (field.key)}
259
+ <option value={field.key}>
260
+ {field.label} ({field.key}) {field.required ? '*' : ''}
261
+ </option>
262
+ {/each}
263
+ </select>
264
+ </div>
265
+ </div>
266
+ {/each}
267
+ </div>
268
+
269
+ <div class="flex items-center justify-between pt-2">
270
+ <Button variant="outline" size="sm" onclick={() => { currentStep = 1; }}>
271
+ <ArrowLeft class="h-3.5 w-3.5 mr-1" />
272
+ {i18n.t('common.back', { defaultValue: 'Back' })}
273
+ </Button>
274
+ <Button size="sm" onclick={startImport}>
275
+ {i18n.t('common.startImport', { defaultValue: 'Start Import' })}
276
+ <ArrowRight class="h-3.5 w-3.5 ml-1" />
277
+ </Button>
278
+ </div>
279
+ </div>
280
+ {/if}
281
+
282
+ <!-- Step 3: Progress & Summary -->
283
+ {#if currentStep === 3}
284
+ <div class="space-y-5 py-2">
285
+ <div class="space-y-2">
286
+ <div class="flex items-center justify-between text-xs">
287
+ <span class="text-muted-foreground">{isImporting ? i18n.t('common.importing', { defaultValue: 'Importing...' }) : i18n.t('common.completed', { defaultValue: 'Completed' })}</span>
288
+ <span class="font-semibold text-foreground tabular-nums">{progressPercent}%</span>
289
+ </div>
290
+ <Progress value={progressPercent} class="h-2" />
291
+ </div>
292
+
293
+ <div class="grid grid-cols-2 gap-3 text-center">
294
+ <div class="p-3 rounded-lg bg-success/10 border border-success/20">
295
+ <span class="text-xs text-muted-foreground block">{i18n.t('common.succeeded', { defaultValue: 'Succeeded' })}</span>
296
+ <span class="text-xl font-bold text-success tabular-nums">{succeededCount}</span>
297
+ </div>
298
+ <div class="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
299
+ <span class="text-xs text-muted-foreground block">{i18n.t('common.failed', { defaultValue: 'Failed' })}</span>
300
+ <span class="text-xl font-bold text-destructive tabular-nums">{failedRecords.length}</span>
301
+ </div>
302
+ </div>
303
+
304
+ {#if failedRecords.length > 0}
305
+ <div class="flex items-center justify-between p-3 rounded-lg bg-destructive/5 border border-destructive/15 text-xs">
306
+ <div class="flex items-center gap-2 text-destructive">
307
+ <AlertCircle class="h-4 w-4" />
308
+ <span>{failedRecords.length} {i18n.t('common.failedRowsHint', { defaultValue: 'records failed to import.' })}</span>
309
+ </div>
310
+ <Button variant="outline" size="sm" class="h-7 text-xs gap-1" onclick={downloadErrors}>
311
+ <Download class="h-3.5 w-3.5" />
312
+ {i18n.t('common.downloadErrors', { defaultValue: 'Download Failed CSV' })}
313
+ </Button>
314
+ </div>
315
+ {/if}
316
+
317
+ <div class="flex items-center justify-end gap-2 pt-3 border-t border-border/50">
318
+ {#if !isImporting}
319
+ <Button variant="outline" size="sm" onclick={reset}>
320
+ {i18n.t('common.importAnother', { defaultValue: 'Import Another' })}
321
+ </Button>
322
+ <Button size="sm" onclick={() => { open = false; }}>
323
+ {i18n.t('common.close', { defaultValue: 'Close' })}
324
+ </Button>
325
+ {:else}
326
+ <Button size="sm" disabled class="gap-2">
327
+ <Loader2 class="h-3.5 w-3.5 animate-spin" />
328
+ {i18n.t('common.processing', { defaultValue: 'Processing...' })}
329
+ </Button>
330
+ {/if}
331
+ </div>
332
+ </div>
333
+ {/if}
334
+ </Dialog.DialogContent>
335
+ </Dialog.Dialog>
336
+ {/if}
@@ -0,0 +1,12 @@
1
+ interface Props {
2
+ resourceName: string;
3
+ open?: boolean;
4
+ batchSize?: number;
5
+ onSuccess?: (result: {
6
+ succeeded: number;
7
+ failed: number;
8
+ }) => void;
9
+ }
10
+ declare const ImportWizard: import("svelte").Component<Props, {}, "open">;
11
+ type ImportWizard = ReturnType<typeof ImportWizard>;
12
+ export default ImportWizard;