@skalfa/skalfa-app 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +43 -43
- package/.github/workflows/publish.yml +39 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -0
- package/README.md +91 -28
- package/app/auth/edit/page.tsx +65 -65
- package/app/auth/login/page.tsx +63 -63
- package/app/auth/me/page.tsx +58 -58
- package/app/auth/register/page.tsx +69 -69
- package/app/auth/verify/page.tsx +53 -53
- package/app/dashboard/user/page.tsx +76 -76
- package/app/layout.tsx +37 -37
- package/app/manifest.ts +25 -0
- package/app/page.tsx +13 -13
- package/barrels.json +5 -5
- package/blueprints/starter.blueprint.json +102 -102
- package/bun.lock +916 -0
- package/components/base.components/chip/Chip.component.tsx +39 -39
- package/components/base.components/document/DocumentViewer.component.tsx +163 -163
- package/components/base.components/document/ExportExcel.component.tsx +340 -340
- package/components/base.components/document/ImportExcel.component.tsx +315 -315
- package/components/base.components/document/PrintTable.component.tsx +204 -204
- package/components/base.components/document/RenderPDF.component.tsx +415 -415
- package/components/base.components/input/Checkbox.component.tsx +109 -109
- package/components/base.components/input/Input.component.tsx +332 -332
- package/components/base.components/input/InputCheckbox.component.tsx +174 -174
- package/components/base.components/input/InputCurrency.component.tsx +163 -163
- package/components/base.components/input/InputDate.component.tsx +352 -352
- package/components/base.components/input/InputDatetime.component.tsx +260 -260
- package/components/base.components/input/InputDocument.component.tsx +351 -351
- package/components/base.components/input/InputImage.component.tsx +533 -533
- package/components/base.components/input/InputMap.component.tsx +317 -317
- package/components/base.components/input/InputNumber.component.tsx +192 -192
- package/components/base.components/input/InputOtp.component.tsx +169 -169
- package/components/base.components/input/InputPassword.component.tsx +236 -236
- package/components/base.components/input/InputRadio.component.tsx +175 -175
- package/components/base.components/input/InputTime.component.tsx +275 -275
- package/components/base.components/input/InputValues.component.tsx +68 -68
- package/components/base.components/input/Radio.component.tsx +102 -102
- package/components/base.components/input/Select.component.tsx +541 -541
- package/components/base.components/modal/BottomSheet.component.tsx +245 -245
- package/components/base.components/supervision/FormSupervision.component.tsx +433 -433
- package/components/base.components/supervision/TableSupervision.component.tsx +697 -697
- package/components/base.components/table/ControlBar.component.tsx +497 -497
- package/components/base.components/table/FilterComponent.tsx +518 -518
- package/components/base.components/table/Table.component.tsx +469 -469
- package/components/base.components/typography/TypographyArticle.component.tsx +26 -26
- package/components/base.components/typography/TypographyColumn.component.tsx +20 -20
- package/components/base.components/typography/TypographyContent.component.tsx +20 -20
- package/components/base.components/typography/TypographyTips.component.tsx +20 -20
- package/components/base.components/wrap/Draggable.component.tsx +303 -303
- package/components/base.components/wrap/IDBProvider.tsx +12 -12
- package/components/base.components/wrap/Image.component.tsx +9 -9
- package/components/base.components/wrap/ShortcutProvider.tsx +57 -57
- package/components/base.components/wrap/Swipe.component.tsx +93 -93
- package/components/index.ts +2 -2
- package/contexts/AppProvider.tsx +11 -11
- package/contexts/Auth.context.tsx +64 -64
- package/contexts/Toggle.context.tsx +44 -44
- package/next.config.ts +15 -1
- package/package.json +14 -13
- package/public/204.svg +19 -19
- package/public/500.svg +39 -39
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/images/logo-fill.png +0 -0
- package/public/images/logo-full-fill.png +0 -0
- package/public/images/logo-full.png +0 -0
- package/public/images/logo.png +0 -0
- package/schema/idb/app.schema.ts +8 -8
- package/src-tauri/Cargo.toml +14 -0
- package/src-tauri/build.rs +3 -0
- package/src-tauri/capabilities/default.json +11 -0
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/src/main.rs +7 -0
- package/src-tauri/tauri.conf.json +36 -0
- package/styles/globals.css +231 -231
- package/styles/tailwind.safelist +68 -68
- package/utils/commands/barrels.ts +27 -27
- package/utils/commands/light.ts +21 -21
- package/utils/commands/logger.ts +42 -42
- package/utils/commands/stubs/table-blueprint.stub +12 -12
- package/utils/commands/use-pdf.ts +29 -29
|
@@ -1,315 +1,315 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
4
|
-
import ExcelJS from "exceljs";
|
|
5
|
-
import { faEdit } from "@fortawesome/free-solid-svg-icons";
|
|
6
|
-
import { TableComponent, ButtonComponent, SelectComponent, ModalComponent, IconButtonComponent } from "@components";
|
|
7
|
-
import { useToggleContext } from "@contexts";
|
|
8
|
-
import { api, FetchControlType } from "@utils";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
export type ImportExcelColumnControlType = {
|
|
13
|
-
label: string;
|
|
14
|
-
selector: string;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type ImportColumn = {
|
|
18
|
-
selector: string;
|
|
19
|
-
label: string;
|
|
20
|
-
source: string | null;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
type ImportExcelProps = {
|
|
24
|
-
columnControl: ImportExcelColumnControlType[];
|
|
25
|
-
onSubmit?: (rows: any[]) => void;
|
|
26
|
-
submitControl?: FetchControlType;
|
|
27
|
-
fetchControl?: FetchControlType;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function numberToExcelColumn(index: number): string
|
|
33
|
-
{
|
|
34
|
-
let column = "";
|
|
35
|
-
let n = index;
|
|
36
|
-
|
|
37
|
-
while (n >= 0) {
|
|
38
|
-
column = String.fromCharCode((n % 26) + 65) + column;
|
|
39
|
-
n = Math.floor(n / 26) - 1;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return column;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export function ImportExcel({ columnControl, onSubmit, submitControl, fetchControl }: ImportExcelProps) {
|
|
48
|
-
const { toggle, setToggle } = useToggleContext()
|
|
49
|
-
|
|
50
|
-
const [columns, setColumns] = useState<ImportColumn[]>([]);
|
|
51
|
-
const [rows, setRows] = useState<Record<string, any>[]>([]);
|
|
52
|
-
const [loaded, setLoaded] = useState(false);
|
|
53
|
-
|
|
54
|
-
const [processing, setProcessing] = useState(false);
|
|
55
|
-
const [progress, setProgress] = useState({ success: 0, failed: 0, total: 0 });
|
|
56
|
-
const [errors, setErrors] = useState<Record<number, string>>({});
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const handleImportFile = async (file: File) => {
|
|
60
|
-
const workbook = new ExcelJS.Workbook();
|
|
61
|
-
const buffer = await file.arrayBuffer();
|
|
62
|
-
|
|
63
|
-
await workbook.xlsx.load(buffer);
|
|
64
|
-
const sheet = workbook.worksheets[0];
|
|
65
|
-
|
|
66
|
-
const excelColumns: ImportColumn[] = [];
|
|
67
|
-
sheet.getRow(1).eachCell((_, colIndex) => {
|
|
68
|
-
const label = numberToExcelColumn(colIndex - 1);
|
|
69
|
-
|
|
70
|
-
excelColumns.push({
|
|
71
|
-
selector: label,
|
|
72
|
-
label: label,
|
|
73
|
-
source: null,
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const excelRows: Record<string, any>[] = [];
|
|
78
|
-
sheet.eachRow((row, rowIndex) => {
|
|
79
|
-
if (rowIndex === 1) return;
|
|
80
|
-
|
|
81
|
-
const item: Record<string, any> = {};
|
|
82
|
-
excelColumns.forEach((col, i) => {
|
|
83
|
-
item[col.selector] = row.getCell(i + 1).value;
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
excelRows.push(item);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
setColumns(excelColumns);
|
|
90
|
-
setRows(excelRows);
|
|
91
|
-
setLoaded(true);
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const getColumnLabel = (source: string | null) => {
|
|
95
|
-
if (!source) return "";
|
|
96
|
-
|
|
97
|
-
const found = columnControl?.find(c => c.selector === source);
|
|
98
|
-
|
|
99
|
-
return found?.label ?? source;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const tableColumns = useMemo(() => {
|
|
104
|
-
return columns?.map((c => ({
|
|
105
|
-
...c,
|
|
106
|
-
label: <div className="w-full text-center">{c.label}</div>
|
|
107
|
-
})));
|
|
108
|
-
}, [columns]);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const tableData = useMemo(() => {
|
|
112
|
-
if (!loaded) return [];
|
|
113
|
-
|
|
114
|
-
const mappingRow: Record<string, any> = {};
|
|
115
|
-
|
|
116
|
-
columns.forEach(col => {
|
|
117
|
-
mappingRow[col.selector] = (
|
|
118
|
-
<>
|
|
119
|
-
<div className="flex justify-between">
|
|
120
|
-
<p className="font-semibold">{getColumnLabel(col.source) || <p className="text-light-foreground">-- PILIH KOLOM --</p>}</p>
|
|
121
|
-
|
|
122
|
-
<IconButtonComponent
|
|
123
|
-
icon={faEdit}
|
|
124
|
-
size="xs"
|
|
125
|
-
paint="warning"
|
|
126
|
-
variant="outline"
|
|
127
|
-
disabled={columns.length <= 1}
|
|
128
|
-
onClick={() => setToggle("MODAL_FIELD_IMPORT", { selector: col.selector, value: col.source })}
|
|
129
|
-
/>
|
|
130
|
-
</div>
|
|
131
|
-
</>
|
|
132
|
-
);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
return [mappingRow, ...rows];
|
|
136
|
-
}, [columns, rows, loaded, columnControl]);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const generatePayload = () => {
|
|
140
|
-
return rows.map(row => {
|
|
141
|
-
const payload: Record<string, any> = {};
|
|
142
|
-
|
|
143
|
-
columns.forEach(col => {
|
|
144
|
-
if (col.source) {
|
|
145
|
-
payload[col.source] = row[col.selector];
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
return payload;
|
|
150
|
-
});
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const handleSubmit = async () => {
|
|
154
|
-
const payload = generatePayload();
|
|
155
|
-
|
|
156
|
-
if (onSubmit) {
|
|
157
|
-
onSubmit(payload);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
setProcessing(true);
|
|
162
|
-
setProgress({ success: 0, failed: 0, total: payload.length });
|
|
163
|
-
setErrors({});
|
|
164
|
-
|
|
165
|
-
if (submitControl?.path) {
|
|
166
|
-
try {
|
|
167
|
-
await api({
|
|
168
|
-
path : submitControl?.path,
|
|
169
|
-
method : "POST",
|
|
170
|
-
payload : { data: payload },
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
setProgress({ success: payload.length, failed: 0, total: payload.length });
|
|
174
|
-
setToggle("MODAL_IMPORT_SUCCESS", true);
|
|
175
|
-
|
|
176
|
-
setTimeout(() => {
|
|
177
|
-
setToggle(Object.keys(toggle).find(k => k.startsWith("MODAL_IMPORT_")) || "", false);
|
|
178
|
-
}, 1500);
|
|
179
|
-
|
|
180
|
-
} catch (e: any) {
|
|
181
|
-
setProgress({ success: 0, failed: payload.length, total: payload.length });
|
|
182
|
-
setErrors({ 0: e.message || "Bulk import failed" });
|
|
183
|
-
}
|
|
184
|
-
} else if (fetchControl) {
|
|
185
|
-
let successCount = 0;
|
|
186
|
-
let failedCount = 0;
|
|
187
|
-
|
|
188
|
-
for (let i = 0; i < payload.length; i++) {
|
|
189
|
-
const item = payload[i];
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
await api({
|
|
193
|
-
...fetchControl,
|
|
194
|
-
method: "POST",
|
|
195
|
-
payload: item,
|
|
196
|
-
});
|
|
197
|
-
successCount++;
|
|
198
|
-
} catch (e: any) {
|
|
199
|
-
failedCount++;
|
|
200
|
-
setErrors(prev => ({ ...prev, [i]: e.message || "Failed" }));
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
setProgress({ success: successCount, failed: failedCount, total: payload.length });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
setProcessing(false);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
return (
|
|
211
|
-
<>
|
|
212
|
-
|
|
213
|
-
{!loaded && (
|
|
214
|
-
<div className="p-4 relative">
|
|
215
|
-
<input
|
|
216
|
-
type="file"
|
|
217
|
-
accept=".xlsx"
|
|
218
|
-
onChange={e =>
|
|
219
|
-
{
|
|
220
|
-
const file = e.target.files?.[0];
|
|
221
|
-
if (file) handleImportFile(file);
|
|
222
|
-
}}
|
|
223
|
-
className="text-transparent bg-background w-full aspect-video border border-dashed relative file:hidden placeholder:hidden rounded-md cursor-pointer"
|
|
224
|
-
/>
|
|
225
|
-
|
|
226
|
-
<div className="absolute top-1/2 left-1/2 -translate-1/2 text-light-foreground">
|
|
227
|
-
Pilih atau tarik file excel di sini
|
|
228
|
-
</div>
|
|
229
|
-
</div>
|
|
230
|
-
)}
|
|
231
|
-
|
|
232
|
-
{loaded && (
|
|
233
|
-
<TableComponent
|
|
234
|
-
controlBar={false}
|
|
235
|
-
columns={tableColumns}
|
|
236
|
-
data={tableData}
|
|
237
|
-
pagination={false}
|
|
238
|
-
noIndex
|
|
239
|
-
className="p-4 bg-background row::bg-background row::border-0 row::gap-0 row::!hover:bg-background column::p-2 column::border head-column::p-2 head-column::border"
|
|
240
|
-
/>
|
|
241
|
-
)}
|
|
242
|
-
|
|
243
|
-
{loaded && !processing && (
|
|
244
|
-
<div className="px-4 mt-8">
|
|
245
|
-
<ButtonComponent
|
|
246
|
-
label="Import Data"
|
|
247
|
-
block
|
|
248
|
-
onClick={handleSubmit}
|
|
249
|
-
/>
|
|
250
|
-
</div>
|
|
251
|
-
)}
|
|
252
|
-
|
|
253
|
-
{processing && (
|
|
254
|
-
<div className="px-4 mt-8 flex flex-col gap-2">
|
|
255
|
-
<div className="flex justify-between text-sm">
|
|
256
|
-
<span>Processing: {progress.success + progress.failed} / {progress.total}</span>
|
|
257
|
-
{progress.failed > 0 && <span className="text-danger">{progress.failed} Failed</span>}
|
|
258
|
-
</div>
|
|
259
|
-
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
|
260
|
-
<div
|
|
261
|
-
className="h-full bg-primary transition-all duration-300"
|
|
262
|
-
style={{ width: `${((progress.success + progress.failed) / progress.total) * 100}%` }}
|
|
263
|
-
/>
|
|
264
|
-
</div>
|
|
265
|
-
</div>
|
|
266
|
-
)}
|
|
267
|
-
|
|
268
|
-
{Object.keys(errors).length > 0 && (
|
|
269
|
-
<div className="px-4 mt-4 max-h-40 overflow-y-auto">
|
|
270
|
-
<p className="text-danger font-semibold mb-2">Errors Details:</p>
|
|
271
|
-
{Object.entries(errors).map(([idx, msg]) => (
|
|
272
|
-
<div key={idx} className="text-xs text-danger">
|
|
273
|
-
Row {Number(idx) + 1}: {msg}
|
|
274
|
-
</div>
|
|
275
|
-
))}
|
|
276
|
-
</div>
|
|
277
|
-
)}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
<ModalComponent
|
|
281
|
-
show={!!toggle["MODAL_FIELD_IMPORT"]}
|
|
282
|
-
onClose={() => setToggle("MODAL_FIELD_IMPORT", false)}
|
|
283
|
-
title="Pilih Kolom"
|
|
284
|
-
footer={
|
|
285
|
-
<div className="flex justify-end">
|
|
286
|
-
<ButtonComponent
|
|
287
|
-
label="Terapkan"
|
|
288
|
-
onClick={() => {
|
|
289
|
-
if (!!(toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value) {
|
|
290
|
-
setColumns(prev =>
|
|
291
|
-
prev.map(c => c.selector === (toggle["MODAL_FIELD_IMPORT"] as { selector: string })?.selector ? { ...c, source: String((toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value) } : c)
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
setToggle("MODAL_FIELD_IMPORT", false)
|
|
295
|
-
}}
|
|
296
|
-
/>
|
|
297
|
-
</div>
|
|
298
|
-
}
|
|
299
|
-
>
|
|
300
|
-
<div className="p-4">
|
|
301
|
-
<SelectComponent
|
|
302
|
-
name={`column_${(toggle["MODAL_FIELD_IMPORT"] as { selector: string })?.selector}`}
|
|
303
|
-
placeholder="Pilih kolom data..."
|
|
304
|
-
value={(toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value ?? ""}
|
|
305
|
-
onChange={e => setToggle("MODAL_FIELD_IMPORT", { ...(toggle["MODAL_FIELD_IMPORT"] as object), value: e })}
|
|
306
|
-
options={columnControl.map(c => ({
|
|
307
|
-
label: c.label,
|
|
308
|
-
value: c.selector,
|
|
309
|
-
}))}
|
|
310
|
-
/>
|
|
311
|
-
</div>
|
|
312
|
-
</ModalComponent>
|
|
313
|
-
</>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import ExcelJS from "exceljs";
|
|
5
|
+
import { faEdit } from "@fortawesome/free-solid-svg-icons";
|
|
6
|
+
import { TableComponent, ButtonComponent, SelectComponent, ModalComponent, IconButtonComponent } from "@components";
|
|
7
|
+
import { useToggleContext } from "@contexts";
|
|
8
|
+
import { api, FetchControlType } from "@utils";
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export type ImportExcelColumnControlType = {
|
|
13
|
+
label: string;
|
|
14
|
+
selector: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ImportColumn = {
|
|
18
|
+
selector: string;
|
|
19
|
+
label: string;
|
|
20
|
+
source: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type ImportExcelProps = {
|
|
24
|
+
columnControl: ImportExcelColumnControlType[];
|
|
25
|
+
onSubmit?: (rows: any[]) => void;
|
|
26
|
+
submitControl?: FetchControlType;
|
|
27
|
+
fetchControl?: FetchControlType;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
function numberToExcelColumn(index: number): string
|
|
33
|
+
{
|
|
34
|
+
let column = "";
|
|
35
|
+
let n = index;
|
|
36
|
+
|
|
37
|
+
while (n >= 0) {
|
|
38
|
+
column = String.fromCharCode((n % 26) + 65) + column;
|
|
39
|
+
n = Math.floor(n / 26) - 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return column;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
export function ImportExcel({ columnControl, onSubmit, submitControl, fetchControl }: ImportExcelProps) {
|
|
48
|
+
const { toggle, setToggle } = useToggleContext()
|
|
49
|
+
|
|
50
|
+
const [columns, setColumns] = useState<ImportColumn[]>([]);
|
|
51
|
+
const [rows, setRows] = useState<Record<string, any>[]>([]);
|
|
52
|
+
const [loaded, setLoaded] = useState(false);
|
|
53
|
+
|
|
54
|
+
const [processing, setProcessing] = useState(false);
|
|
55
|
+
const [progress, setProgress] = useState({ success: 0, failed: 0, total: 0 });
|
|
56
|
+
const [errors, setErrors] = useState<Record<number, string>>({});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
const handleImportFile = async (file: File) => {
|
|
60
|
+
const workbook = new ExcelJS.Workbook();
|
|
61
|
+
const buffer = await file.arrayBuffer();
|
|
62
|
+
|
|
63
|
+
await workbook.xlsx.load(buffer);
|
|
64
|
+
const sheet = workbook.worksheets[0];
|
|
65
|
+
|
|
66
|
+
const excelColumns: ImportColumn[] = [];
|
|
67
|
+
sheet.getRow(1).eachCell((_, colIndex) => {
|
|
68
|
+
const label = numberToExcelColumn(colIndex - 1);
|
|
69
|
+
|
|
70
|
+
excelColumns.push({
|
|
71
|
+
selector: label,
|
|
72
|
+
label: label,
|
|
73
|
+
source: null,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const excelRows: Record<string, any>[] = [];
|
|
78
|
+
sheet.eachRow((row, rowIndex) => {
|
|
79
|
+
if (rowIndex === 1) return;
|
|
80
|
+
|
|
81
|
+
const item: Record<string, any> = {};
|
|
82
|
+
excelColumns.forEach((col, i) => {
|
|
83
|
+
item[col.selector] = row.getCell(i + 1).value;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
excelRows.push(item);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
setColumns(excelColumns);
|
|
90
|
+
setRows(excelRows);
|
|
91
|
+
setLoaded(true);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const getColumnLabel = (source: string | null) => {
|
|
95
|
+
if (!source) return "";
|
|
96
|
+
|
|
97
|
+
const found = columnControl?.find(c => c.selector === source);
|
|
98
|
+
|
|
99
|
+
return found?.label ?? source;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
const tableColumns = useMemo(() => {
|
|
104
|
+
return columns?.map((c => ({
|
|
105
|
+
...c,
|
|
106
|
+
label: <div className="w-full text-center">{c.label}</div>
|
|
107
|
+
})));
|
|
108
|
+
}, [columns]);
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
const tableData = useMemo(() => {
|
|
112
|
+
if (!loaded) return [];
|
|
113
|
+
|
|
114
|
+
const mappingRow: Record<string, any> = {};
|
|
115
|
+
|
|
116
|
+
columns.forEach(col => {
|
|
117
|
+
mappingRow[col.selector] = (
|
|
118
|
+
<>
|
|
119
|
+
<div className="flex justify-between">
|
|
120
|
+
<p className="font-semibold">{getColumnLabel(col.source) || <p className="text-light-foreground">-- PILIH KOLOM --</p>}</p>
|
|
121
|
+
|
|
122
|
+
<IconButtonComponent
|
|
123
|
+
icon={faEdit}
|
|
124
|
+
size="xs"
|
|
125
|
+
paint="warning"
|
|
126
|
+
variant="outline"
|
|
127
|
+
disabled={columns.length <= 1}
|
|
128
|
+
onClick={() => setToggle("MODAL_FIELD_IMPORT", { selector: col.selector, value: col.source })}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return [mappingRow, ...rows];
|
|
136
|
+
}, [columns, rows, loaded, columnControl]);
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
const generatePayload = () => {
|
|
140
|
+
return rows.map(row => {
|
|
141
|
+
const payload: Record<string, any> = {};
|
|
142
|
+
|
|
143
|
+
columns.forEach(col => {
|
|
144
|
+
if (col.source) {
|
|
145
|
+
payload[col.source] = row[col.selector];
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return payload;
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const handleSubmit = async () => {
|
|
154
|
+
const payload = generatePayload();
|
|
155
|
+
|
|
156
|
+
if (onSubmit) {
|
|
157
|
+
onSubmit(payload);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setProcessing(true);
|
|
162
|
+
setProgress({ success: 0, failed: 0, total: payload.length });
|
|
163
|
+
setErrors({});
|
|
164
|
+
|
|
165
|
+
if (submitControl?.path) {
|
|
166
|
+
try {
|
|
167
|
+
await api({
|
|
168
|
+
path : submitControl?.path,
|
|
169
|
+
method : "POST",
|
|
170
|
+
payload : { data: payload },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
setProgress({ success: payload.length, failed: 0, total: payload.length });
|
|
174
|
+
setToggle("MODAL_IMPORT_SUCCESS", true);
|
|
175
|
+
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
setToggle(Object.keys(toggle).find(k => k.startsWith("MODAL_IMPORT_")) || "", false);
|
|
178
|
+
}, 1500);
|
|
179
|
+
|
|
180
|
+
} catch (e: any) {
|
|
181
|
+
setProgress({ success: 0, failed: payload.length, total: payload.length });
|
|
182
|
+
setErrors({ 0: e.message || "Bulk import failed" });
|
|
183
|
+
}
|
|
184
|
+
} else if (fetchControl) {
|
|
185
|
+
let successCount = 0;
|
|
186
|
+
let failedCount = 0;
|
|
187
|
+
|
|
188
|
+
for (let i = 0; i < payload.length; i++) {
|
|
189
|
+
const item = payload[i];
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
await api({
|
|
193
|
+
...fetchControl,
|
|
194
|
+
method: "POST",
|
|
195
|
+
payload: item,
|
|
196
|
+
});
|
|
197
|
+
successCount++;
|
|
198
|
+
} catch (e: any) {
|
|
199
|
+
failedCount++;
|
|
200
|
+
setErrors(prev => ({ ...prev, [i]: e.message || "Failed" }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setProgress({ success: successCount, failed: failedCount, total: payload.length });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setProcessing(false);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
|
|
213
|
+
{!loaded && (
|
|
214
|
+
<div className="p-4 relative">
|
|
215
|
+
<input
|
|
216
|
+
type="file"
|
|
217
|
+
accept=".xlsx"
|
|
218
|
+
onChange={e =>
|
|
219
|
+
{
|
|
220
|
+
const file = e.target.files?.[0];
|
|
221
|
+
if (file) handleImportFile(file);
|
|
222
|
+
}}
|
|
223
|
+
className="text-transparent bg-background w-full aspect-video border border-dashed relative file:hidden placeholder:hidden rounded-md cursor-pointer"
|
|
224
|
+
/>
|
|
225
|
+
|
|
226
|
+
<div className="absolute top-1/2 left-1/2 -translate-1/2 text-light-foreground">
|
|
227
|
+
Pilih atau tarik file excel di sini
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{loaded && (
|
|
233
|
+
<TableComponent
|
|
234
|
+
controlBar={false}
|
|
235
|
+
columns={tableColumns}
|
|
236
|
+
data={tableData}
|
|
237
|
+
pagination={false}
|
|
238
|
+
noIndex
|
|
239
|
+
className="p-4 bg-background row::bg-background row::border-0 row::gap-0 row::!hover:bg-background column::p-2 column::border head-column::p-2 head-column::border"
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{loaded && !processing && (
|
|
244
|
+
<div className="px-4 mt-8">
|
|
245
|
+
<ButtonComponent
|
|
246
|
+
label="Import Data"
|
|
247
|
+
block
|
|
248
|
+
onClick={handleSubmit}
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{processing && (
|
|
254
|
+
<div className="px-4 mt-8 flex flex-col gap-2">
|
|
255
|
+
<div className="flex justify-between text-sm">
|
|
256
|
+
<span>Processing: {progress.success + progress.failed} / {progress.total}</span>
|
|
257
|
+
{progress.failed > 0 && <span className="text-danger">{progress.failed} Failed</span>}
|
|
258
|
+
</div>
|
|
259
|
+
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
|
|
260
|
+
<div
|
|
261
|
+
className="h-full bg-primary transition-all duration-300"
|
|
262
|
+
style={{ width: `${((progress.success + progress.failed) / progress.total) * 100}%` }}
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{Object.keys(errors).length > 0 && (
|
|
269
|
+
<div className="px-4 mt-4 max-h-40 overflow-y-auto">
|
|
270
|
+
<p className="text-danger font-semibold mb-2">Errors Details:</p>
|
|
271
|
+
{Object.entries(errors).map(([idx, msg]) => (
|
|
272
|
+
<div key={idx} className="text-xs text-danger">
|
|
273
|
+
Row {Number(idx) + 1}: {msg}
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
<ModalComponent
|
|
281
|
+
show={!!toggle["MODAL_FIELD_IMPORT"]}
|
|
282
|
+
onClose={() => setToggle("MODAL_FIELD_IMPORT", false)}
|
|
283
|
+
title="Pilih Kolom"
|
|
284
|
+
footer={
|
|
285
|
+
<div className="flex justify-end">
|
|
286
|
+
<ButtonComponent
|
|
287
|
+
label="Terapkan"
|
|
288
|
+
onClick={() => {
|
|
289
|
+
if (!!(toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value) {
|
|
290
|
+
setColumns(prev =>
|
|
291
|
+
prev.map(c => c.selector === (toggle["MODAL_FIELD_IMPORT"] as { selector: string })?.selector ? { ...c, source: String((toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value) } : c)
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
setToggle("MODAL_FIELD_IMPORT", false)
|
|
295
|
+
}}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
}
|
|
299
|
+
>
|
|
300
|
+
<div className="p-4">
|
|
301
|
+
<SelectComponent
|
|
302
|
+
name={`column_${(toggle["MODAL_FIELD_IMPORT"] as { selector: string })?.selector}`}
|
|
303
|
+
placeholder="Pilih kolom data..."
|
|
304
|
+
value={(toggle["MODAL_FIELD_IMPORT"] as { value: string })?.value ?? ""}
|
|
305
|
+
onChange={e => setToggle("MODAL_FIELD_IMPORT", { ...(toggle["MODAL_FIELD_IMPORT"] as object), value: e })}
|
|
306
|
+
options={columnControl.map(c => ({
|
|
307
|
+
label: c.label,
|
|
308
|
+
value: c.selector,
|
|
309
|
+
}))}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
</ModalComponent>
|
|
313
|
+
</>
|
|
314
|
+
);
|
|
315
|
+
}
|