@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.
- package/dist/components/ActivityFeed.svelte +135 -0
- package/dist/components/ActivityFeed.svelte.d.ts +21 -0
- package/dist/components/ApprovalActionCard.svelte +248 -0
- package/dist/components/ApprovalActionCard.svelte.d.ts +22 -0
- package/dist/components/AutoTable.svelte +8 -0
- package/dist/components/AutoTable.svelte.d.ts +7 -1
- package/dist/components/CanvasAnnotation.svelte +389 -0
- package/dist/components/CanvasAnnotation.svelte.d.ts +17 -0
- package/dist/components/Cascader.svelte +205 -0
- package/dist/components/Cascader.svelte.d.ts +21 -0
- package/dist/components/ColumnHeaderFilter.svelte +178 -0
- package/dist/components/ColumnHeaderFilter.svelte.d.ts +12 -0
- package/dist/components/ColumnSettings.svelte +198 -0
- package/dist/components/ColumnSettings.svelte.d.ts +19 -0
- package/dist/components/DecisionTable.svelte +240 -0
- package/dist/components/DecisionTable.svelte.d.ts +22 -0
- package/dist/components/DraggableRowTable.svelte +117 -0
- package/dist/components/DraggableRowTable.svelte.d.ts +15 -0
- package/dist/components/DrawerForm.svelte +110 -26
- package/dist/components/DrawerForm.svelte.d.ts +20 -8
- package/dist/components/DynamicFormList.svelte +214 -0
- package/dist/components/DynamicFormList.svelte.d.ts +62 -0
- package/dist/components/EditableTable.svelte +195 -0
- package/dist/components/EditableTable.svelte.d.ts +25 -0
- package/dist/components/FieldRenderer.svelte +32 -0
- package/dist/components/FilterBuilder.svelte +310 -0
- package/dist/components/FilterBuilder.svelte.d.ts +23 -0
- package/dist/components/GanttChart.svelte +144 -0
- package/dist/components/GanttChart.svelte.d.ts +20 -0
- package/dist/components/ImageCropper.svelte +122 -0
- package/dist/components/ImageCropper.svelte.d.ts +12 -0
- package/dist/components/ImportWizard.svelte +336 -0
- package/dist/components/ImportWizard.svelte.d.ts +12 -0
- package/dist/components/JsonSchemaForm.svelte +149 -0
- package/dist/components/JsonSchemaForm.svelte.d.ts +10 -0
- package/dist/components/KanbanBoard.svelte +212 -0
- package/dist/components/KanbanBoard.svelte.d.ts +30 -0
- package/dist/components/MasterDetailView.svelte +134 -0
- package/dist/components/MasterDetailView.svelte.d.ts +16 -0
- package/dist/components/MediaLibraryModal.svelte +195 -0
- package/dist/components/MediaLibraryModal.svelte.d.ts +22 -0
- package/dist/components/MentionsInput.svelte +174 -0
- package/dist/components/MentionsInput.svelte.d.ts +21 -0
- package/dist/components/ModalForm.svelte +113 -28
- package/dist/components/ModalForm.svelte.d.ts +20 -8
- package/dist/components/MultiTabKeepAlive.svelte +143 -0
- package/dist/components/MultiTabKeepAlive.svelte.d.ts +21 -0
- package/dist/components/OfflineSyncBanner.svelte +172 -0
- package/dist/components/OfflineSyncBanner.svelte.d.ts +19 -0
- package/dist/components/PdfDocumentViewer.svelte +255 -0
- package/dist/components/PdfDocumentViewer.svelte.d.ts +22 -0
- package/dist/components/PivotTable.svelte +140 -0
- package/dist/components/PivotTable.svelte.d.ts +16 -0
- package/dist/components/PresenceAvatarGroup.svelte +89 -0
- package/dist/components/PresenceAvatarGroup.svelte.d.ts +15 -0
- package/dist/components/PrintableBill.svelte +149 -0
- package/dist/components/PrintableBill.svelte.d.ts +24 -0
- package/dist/components/SensitiveDataMask.svelte +125 -0
- package/dist/components/SensitiveDataMask.svelte.d.ts +13 -0
- package/dist/components/SignaturePad.svelte +264 -0
- package/dist/components/SignaturePad.svelte.d.ts +17 -0
- package/dist/components/SplitPaneLayout.svelte +113 -0
- package/dist/components/SplitPaneLayout.svelte.d.ts +13 -0
- package/dist/components/SpreadsheetView.svelte +363 -0
- package/dist/components/SpreadsheetView.svelte.d.ts +18 -0
- package/dist/components/StepForm.svelte +180 -0
- package/dist/components/StepForm.svelte.d.ts +23 -0
- package/dist/components/TableSummary.svelte +110 -0
- package/dist/components/TableSummary.svelte.d.ts +20 -0
- package/dist/components/Transfer.svelte +352 -0
- package/dist/components/Transfer.svelte.d.ts +28 -0
- package/dist/components/TreeSelect.svelte +307 -0
- package/dist/components/TreeSelect.svelte.d.ts +22 -0
- package/dist/components/TreeTable.svelte +237 -0
- package/dist/components/TreeTable.svelte.d.ts +28 -0
- package/dist/components/UnsavedChangesPrompt.svelte +44 -0
- package/dist/components/UnsavedChangesPrompt.svelte.d.ts +7 -0
- package/dist/components/VersionDiffViewer.svelte +196 -0
- package/dist/components/VersionDiffViewer.svelte.d.ts +12 -0
- package/dist/components/VirtualTable.svelte +86 -54
- package/dist/components/VirtualTable.svelte.d.ts +14 -21
- package/dist/components/Watermark.svelte +166 -0
- package/dist/components/Watermark.svelte.d.ts +26 -0
- package/dist/components/ui/navigation-menu/index.d.ts +11 -0
- package/dist/components/ui/navigation-menu/index.js +13 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte +21 -0
- package/dist/components/ui/navigation-menu/navigation-menu-content.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-indicator.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-item.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte +20 -0
- package/dist/components/ui/navigation-menu/navigation-menu-link.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte +17 -0
- package/dist/components/ui/navigation-menu/navigation-menu-list.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte +28 -0
- package/dist/components/ui/navigation-menu/navigation-menu-trigger.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte +22 -0
- package/dist/components/ui/navigation-menu/navigation-menu-viewport.svelte.d.ts +4 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte +24 -0
- package/dist/components/ui/navigation-menu/navigation-menu.svelte.d.ts +4 -0
- package/dist/index.d.ts +68 -3
- package/dist/index.js +42 -3
- 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;
|