@mshuja288/aksuicore 0.4.1 → 0.4.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/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@mshuja288/aksuicore",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared UI components and templates for Ikssam applications",
5
5
  "license": "UNLICENSED",
6
- "private": false,
7
6
  "type": "module",
8
7
  "main": "./dist/index.cjs",
9
8
  "module": "./dist/index.js",
@@ -65,12 +65,34 @@ export type ExportColumnRef = {
65
65
  header: string;
66
66
  };
67
67
 
68
+ export type BulkImportMode = "insert" | "update" | "upsert";
69
+
70
+ export type BulkImportError = {
71
+ row: number;
72
+ field?: string | null;
73
+ code: string;
74
+ message: string;
75
+ };
76
+
77
+ export type BulkImportResult = {
78
+ totalRows: number;
79
+ failed: number;
80
+ applied: boolean;
81
+ errors: BulkImportError[];
82
+ };
83
+
68
84
  const EXPORT_FORMAT_OPTIONS: { format: ExportDownloadFormat; label: string }[] = [
69
85
  { format: "csv", label: "CSV" },
70
86
  { format: "xlsx", label: "Excel" },
71
87
  { format: "json", label: "JSON" },
72
88
  ];
73
89
 
90
+ const BULK_IMPORT_MODE_OPTIONS: { mode: BulkImportMode; label: string }[] = [
91
+ { mode: "insert", label: "Insert" },
92
+ { mode: "update", label: "Update" },
93
+ { mode: "upsert", label: "Upsert" },
94
+ ];
95
+
74
96
  type SortDirection = "asc" | "desc";
75
97
  type SortState = { key: string; direction: SortDirection };
76
98
  type DetailLine = { label: string; value: string };
@@ -194,6 +216,16 @@ function PreviewIcon() {
194
216
  );
195
217
  }
196
218
 
219
+ function UploadIcon() {
220
+ return (
221
+ <svg viewBox="0 0 20 20" aria-hidden="true">
222
+ <path d="M10 16.5V7.3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
223
+ <path d="M6.5 10.8 10 7.2l3.5 3.6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
224
+ <path d="M4 4.5h12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
225
+ </svg>
226
+ );
227
+ }
228
+
197
229
  function DownloadIcon() {
198
230
  return (
199
231
  <svg viewBox="0 0 20 20" aria-hidden="true">
@@ -258,7 +290,11 @@ export function RecordWorkspace<T>({
258
290
  canDelete,
259
291
  onDownload,
260
292
  canDownload = true,
293
+ canExport,
261
294
  downloadBusy = false,
295
+ onBulkImport,
296
+ canImport = true,
297
+ importBusy = false,
262
298
  showColumnPicker = true,
263
299
  columnPrefsKey,
264
300
  visibleColumnKeys,
@@ -285,8 +321,13 @@ export function RecordWorkspace<T>({
285
321
  * Second argument lists currently visible columns (exportKey ?? key).
286
322
  */
287
323
  onDownload?: (format: ExportDownloadFormat, columns: ExportColumnRef[]) => void;
324
+ /** Alias for `canDownload` (RBAC). When set, export requires this and `canDownload` to be true. */
325
+ canExport?: boolean;
288
326
  canDownload?: boolean;
289
327
  downloadBusy?: boolean;
328
+ onBulkImport?: (mode: BulkImportMode, file: File) => Promise<BulkImportResult>;
329
+ canImport?: boolean;
330
+ importBusy?: boolean;
290
331
  /** Column checklist in the side rail. Default true. */
291
332
  showColumnPicker?: boolean;
292
333
  /** Persist visible column keys in localStorage under this key. */
@@ -314,6 +355,11 @@ export function RecordWorkspace<T>({
314
355
  const [sideOpen, setSideOpen] = useState(false);
315
356
  const [toolsDrawerOpen, setToolsDrawerOpen] = useState(false);
316
357
  const [downloadOpen, setDownloadOpen] = useState(false);
358
+ const [bulkOpen, setBulkOpen] = useState(false);
359
+ const [bulkMode, setBulkMode] = useState<BulkImportMode>("upsert");
360
+ const [bulkFile, setBulkFile] = useState<File | null>(null);
361
+ const [bulkReport, setBulkReport] = useState<BulkImportResult | null>(null);
362
+ const bulkFileInputRef = useRef<HTMLInputElement | null>(null);
317
363
  const [columnsOpen, setColumnsOpen] = useState(false);
318
364
  const [previewEnabled, setPreviewEnabled] = useState(false);
319
365
  const [previewKey, setPreviewKey] = useState<string | number | null>(null);
@@ -425,7 +471,9 @@ export function RecordWorkspace<T>({
425
471
  const start = showAll ? 0 : (currentPage - 1) * numericPageSize;
426
472
  const pageItems = showAll ? sorted : sorted.slice(start, start + numericPageSize);
427
473
  const showAdd = canAdd && Boolean(onAdd);
428
- const showDownload = Boolean(onDownload) && canDownload;
474
+ const exportAllowed = (canExport ?? true) && canDownload;
475
+ const showDownload = Boolean(onDownload) && exportAllowed;
476
+ const showBulkImport = Boolean(onBulkImport) && canImport;
429
477
  const showFilters = filterableColumns.length > 0;
430
478
  const showColumns = showColumnPicker && columns.length > 0;
431
479
  const previewItem = previewKey === null ? null : sorted.find((item) => rowKey(item) === previewKey) ?? null;
@@ -478,6 +526,30 @@ export function RecordWorkspace<T>({
478
526
  setDownloadOpen(false);
479
527
  }
480
528
 
529
+ async function submitBulkImport() {
530
+ if (!onBulkImport || importBusy || !bulkFile) {
531
+ return;
532
+ }
533
+ setBulkReport(null);
534
+ try {
535
+ const report = await onBulkImport(bulkMode, bulkFile);
536
+ setBulkReport(report);
537
+ if (report.applied) {
538
+ setBulkFile(null);
539
+ if (bulkFileInputRef.current) {
540
+ bulkFileInputRef.current.value = "";
541
+ }
542
+ }
543
+ } catch {
544
+ setBulkReport({
545
+ totalRows: 0,
546
+ failed: 1,
547
+ applied: false,
548
+ errors: [{ row: 0, code: "ImportFailed", message: "Import request failed." }],
549
+ });
550
+ }
551
+ }
552
+
481
553
  function cellText(column: TableColumn<T>, item: T) {
482
554
  const raw = column.display ? column.display(item) : column.value(item);
483
555
  return raw === null || raw === undefined || raw === "" ? "—" : String(raw);
@@ -614,6 +686,21 @@ export function RecordWorkspace<T>({
614
686
  <DownloadIcon />
615
687
  </button>
616
688
  ) : null}
689
+ {showBulkImport ? (
690
+ <button
691
+ type="button"
692
+ className="ghost side-rail-icon"
693
+ title="Bulk entry"
694
+ aria-label="Bulk entry"
695
+ disabled={importBusy}
696
+ onClick={() => {
697
+ setSideOpen(true);
698
+ setBulkOpen(true);
699
+ }}
700
+ >
701
+ <UploadIcon />
702
+ </button>
703
+ ) : null}
617
704
  {showFilters ? (
618
705
  <button
619
706
  type="button"
@@ -742,6 +829,75 @@ export function RecordWorkspace<T>({
742
829
  </div>
743
830
  ) : null}
744
831
 
832
+ {showBulkImport ? (
833
+ <div className="side-rail-bulk">
834
+ <button
835
+ type="button"
836
+ className="ghost side-rail-bulk-toggle"
837
+ aria-expanded={bulkOpen}
838
+ disabled={importBusy}
839
+ onClick={() => setBulkOpen((open) => !open)}
840
+ >
841
+ <UploadIcon />
842
+ <span>{importBusy ? "Importing…" : "Bulk entry"}</span>
843
+ </button>
844
+ {bulkOpen ? (
845
+ <div className="side-rail-bulk-panel" role="group" aria-label="Bulk entry">
846
+ <label className="side-rail-bulk-mode">
847
+ Mode
848
+ <Select
849
+ value={bulkMode}
850
+ options={BULK_IMPORT_MODE_OPTIONS.map((option) => ({ value: option.mode, label: option.label }))}
851
+ onChange={(value) => setBulkMode(value as BulkImportMode)}
852
+ />
853
+ </label>
854
+ <label className="side-rail-bulk-file">
855
+ File
856
+ <input
857
+ ref={bulkFileInputRef}
858
+ type="file"
859
+ accept=".csv,.xlsx,.json"
860
+ disabled={importBusy}
861
+ onChange={(event) => {
862
+ setBulkFile(event.target.files?.[0] ?? null);
863
+ setBulkReport(null);
864
+ }}
865
+ />
866
+ </label>
867
+ <button
868
+ type="button"
869
+ className="side-rail-bulk-submit"
870
+ disabled={importBusy || !bulkFile}
871
+ onClick={() => void submitBulkImport()}
872
+ >
873
+ {importBusy ? "Importing…" : "Import"}
874
+ </button>
875
+ {bulkReport ? (
876
+ <div className="side-rail-bulk-report" role="status">
877
+ <p>
878
+ {bulkReport.applied
879
+ ? `Applied ${bulkReport.totalRows} row(s).`
880
+ : `${bulkReport.failed} error(s) in ${bulkReport.totalRows} row(s).`}
881
+ </p>
882
+ {bulkReport.errors.length > 0 ? (
883
+ <ul className="side-rail-bulk-errors">
884
+ {bulkReport.errors.slice(0, 8).map((error, index) => (
885
+ <li key={`${error.row}-${error.code}-${index}`}>
886
+ Row {error.row}: {error.message}
887
+ </li>
888
+ ))}
889
+ {bulkReport.errors.length > 8 ? (
890
+ <li>…and {bulkReport.errors.length - 8} more</li>
891
+ ) : null}
892
+ </ul>
893
+ ) : null}
894
+ </div>
895
+ ) : null}
896
+ </div>
897
+ ) : null}
898
+ </div>
899
+ ) : null}
900
+
745
901
  {showFilters ? (
746
902
  <div className="side-rail-filters">
747
903
  <div className="filter-panel-head">
@@ -5,4 +5,7 @@ export {
5
5
  type TableColumn,
6
6
  type ExportDownloadFormat,
7
7
  type ExportColumnRef,
8
+ type BulkImportMode,
9
+ type BulkImportError,
10
+ type BulkImportResult,
8
11
  } from "./RecordWorkspace";
@@ -405,7 +405,8 @@ button.side-rail-add:hover {
405
405
  }
406
406
 
407
407
  .side-rail-columns,
408
- .side-rail-download {
408
+ .side-rail-download,
409
+ .side-rail-bulk {
409
410
  display: grid;
410
411
  gap: 8px;
411
412
  }
@@ -533,6 +534,79 @@ button.side-rail-download-format:disabled {
533
534
  cursor: default;
534
535
  }
535
536
 
537
+ button.side-rail-bulk-toggle {
538
+ display: inline-flex;
539
+ align-items: center;
540
+ justify-content: center;
541
+ gap: 8px;
542
+ width: 100%;
543
+ padding: 10px 12px;
544
+ border: 1px solid var(--aks-border, #d7dde5);
545
+ border-radius: var(--aks-radius);
546
+ background: #fff;
547
+ color: var(--aks-brand);
548
+ box-shadow: none;
549
+ }
550
+
551
+ button.side-rail-bulk-toggle svg {
552
+ width: 18px;
553
+ height: 18px;
554
+ }
555
+
556
+ button.side-rail-bulk-toggle:hover:not(:disabled) {
557
+ background: #f5f7fa;
558
+ }
559
+
560
+ button.side-rail-bulk-toggle:disabled {
561
+ opacity: 0.55;
562
+ cursor: default;
563
+ }
564
+
565
+ .side-rail-bulk-panel {
566
+ display: grid;
567
+ gap: 10px;
568
+ padding: 8px;
569
+ border: 1px solid var(--aks-line);
570
+ border-radius: var(--aks-radius);
571
+ background: #faf9f7;
572
+ }
573
+
574
+ label.side-rail-bulk-mode,
575
+ label.side-rail-bulk-file {
576
+ display: grid;
577
+ gap: 6px;
578
+ margin: 0;
579
+ font-size: 13px;
580
+ font-weight: 600;
581
+ color: var(--aks-brand);
582
+ }
583
+
584
+ button.side-rail-bulk-submit {
585
+ width: 100%;
586
+ padding: 10px 12px;
587
+ border: none;
588
+ border-radius: var(--aks-radius);
589
+ background: var(--aks-accent, #c45c26);
590
+ color: #fff;
591
+ font-weight: 600;
592
+ box-shadow: none;
593
+ }
594
+
595
+ button.side-rail-bulk-submit:disabled {
596
+ opacity: 0.5;
597
+ cursor: default;
598
+ }
599
+
600
+ .side-rail-bulk-report {
601
+ font-size: 12px;
602
+ color: var(--aks-brand);
603
+ }
604
+
605
+ .side-rail-bulk-errors {
606
+ margin: 6px 0 0;
607
+ padding-left: 18px;
608
+ }
609
+
536
610
  .side-rail-filters {
537
611
  display: grid;
538
612
  gap: 12px;