From dc7fc02d59d5464b7a56af2a433b354bb894853c Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Tue, 25 Nov 2025 08:40:10 +1000 Subject: [PATCH] Add import/export tooling and templates --- package-lock.json | 107 +++++ package.json | 2 + public/templates/README.txt | 22 + public/templates/areas_template.csv | 3 + public/templates/categories_template.csv | 2 + public/templates/pickitems_template.csv | 2 + public/templates/picklists_template.csv | 2 + public/templates/products_template.csv | 2 + src/App.tsx | 2 + src/components/NavigationBar.tsx | 1 + src/db/index.ts | 13 + src/models/ImportExportLog.ts | 18 + src/screens/ImportExportScreen.tsx | 225 +++++++++ src/services/importExportService.test.ts | 162 +++++++ src/services/importExportService.ts | 575 +++++++++++++++++++++++ 15 files changed, 1138 insertions(+) create mode 100644 public/templates/README.txt create mode 100644 public/templates/areas_template.csv create mode 100644 public/templates/categories_template.csv create mode 100644 public/templates/pickitems_template.csv create mode 100644 public/templates/picklists_template.csv create mode 100644 public/templates/products_template.csv create mode 100644 src/models/ImportExportLog.ts create mode 100644 src/screens/ImportExportScreen.tsx create mode 100644 src/services/importExportService.test.ts create mode 100644 src/services/importExportService.ts diff --git a/package-lock.json b/package-lock.json index cf90a22..792a2fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@zxing/browser": "^0.1.5", "date-fns": "^4.1.0", "dexie": "^4.2.1", + "jszip": "^3.10.1", + "papaparse": "^5.5.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.6", @@ -3269,6 +3271,12 @@ "node": ">=18" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -4168,6 +4176,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4204,6 +4218,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4282,6 +4302,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4454,6 +4480,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4478,6 +4516,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4805,6 +4852,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5040,6 +5099,12 @@ "license": "MIT", "peer": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5179,6 +5244,21 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -5326,6 +5406,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5371,6 +5457,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5479,6 +5571,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5873,6 +5974,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", diff --git a/package.json b/package.json index 89c7f5e..cf24282 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@zxing/browser": "^0.1.5", "date-fns": "^4.1.0", "dexie": "^4.2.1", + "jszip": "^3.10.1", + "papaparse": "^5.5.3", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.6", diff --git a/public/templates/README.txt b/public/templates/README.txt new file mode 100644 index 0000000..2e69739 --- /dev/null +++ b/public/templates/README.txt @@ -0,0 +1,22 @@ +StockFill import/export templates +================================= + +Each CSV is UTF-8 encoded and uses headers as the first row. Fields marked optional can be left blank. + +- ids are optional; missing ids will be generated automatically. +- Names are preferred for mapping relationships (e.g., category name instead of id). + +Files +----- +- areas_template.csv — columns: id, name, created_at, updated_at +- categories_template.csv — columns: id, name, created_at, updated_at +- products_template.csv — columns: id, name, category (name), unit_type, bulk_name, barcode, archived, created_at, updated_at +- picklists_template.csv — columns: id, area_id, area_name, created_at, completed_at, notes, categories (semicolon separated names), auto_add_new_products +- pickitems_template.csv — columns: id, pick_list_id, pick_list_name, product_id, product_name, quantity, is_carton, status, created_at, updated_at + +Notes +----- +- Category and area names are matched case-insensitively. +- Product names are deduplicated case-insensitively; duplicate names will be skipped. +- If a referenced name is missing and auto-create is enabled, the importer will create the required area/category/product. +- Barcode collisions are avoided automatically during import. diff --git a/public/templates/areas_template.csv b/public/templates/areas_template.csv new file mode 100644 index 0000000..1059124 --- /dev/null +++ b/public/templates/areas_template.csv @@ -0,0 +1,3 @@ +id,name,created_at,updated_at +# id optional — you can use empty for new rows. created/updated optional +Area A,, diff --git a/public/templates/categories_template.csv b/public/templates/categories_template.csv new file mode 100644 index 0000000..fd24cfb --- /dev/null +++ b/public/templates/categories_template.csv @@ -0,0 +1,2 @@ +id,name,created_at,updated_at +Clothing,, diff --git a/public/templates/pickitems_template.csv b/public/templates/pickitems_template.csv new file mode 100644 index 0000000..22bbf7d --- /dev/null +++ b/public/templates/pickitems_template.csv @@ -0,0 +1,2 @@ +id,pick_list_id,pick_list_name,product_id,product_name,quantity,is_carton,status,created_at,updated_at +,,"My Pick List",,"Blue T-Shirt",2,false,pending,, diff --git a/public/templates/picklists_template.csv b/public/templates/picklists_template.csv new file mode 100644 index 0000000..19406c2 --- /dev/null +++ b/public/templates/picklists_template.csv @@ -0,0 +1,2 @@ +id,area_id,area_name,created_at,completed_at,notes,categories,auto_add_new_products +,,"Area A",,,"","Clothing;Accessories",false diff --git a/public/templates/products_template.csv b/public/templates/products_template.csv new file mode 100644 index 0000000..4a088f7 --- /dev/null +++ b/public/templates/products_template.csv @@ -0,0 +1,2 @@ +id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at +"Blue T-Shirt","Clothing","unit","carton","0123456789012",false,, diff --git a/src/App.tsx b/src/App.tsx index 8ad21a7..d89b4b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { ManageAreasScreen } from './screens/ManageAreasScreen'; import { ManageCategoriesScreen } from './screens/ManageCategoriesScreen'; import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen'; import { useServiceWorker } from './hooks/useServiceWorker'; +import { ImportExportScreen } from './screens/ImportExportScreen'; const theme = createTheme({ palette: { @@ -34,6 +35,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> ); diff --git a/src/components/NavigationBar.tsx b/src/components/NavigationBar.tsx index b3811ff..53bde5f 100644 --- a/src/components/NavigationBar.tsx +++ b/src/components/NavigationBar.tsx @@ -8,6 +8,7 @@ const navLinks = [ { to: '/products', label: 'Products' }, { to: '/categories', label: 'Categories' }, { to: '/areas', label: 'Areas' }, + { to: '/data', label: 'Import/Export' }, { to: '/scan', label: 'Scan' }, ]; diff --git a/src/db/index.ts b/src/db/index.ts index db94e19..227ae39 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,6 +5,7 @@ import { Category } from '../models/Category'; import { PickItem } from '../models/PickItem'; import { PickList } from '../models/PickList'; import { Product } from '../models/Product'; +import { ImportExportLog } from '../models/ImportExportLog'; import { applyMigrations } from './migrations'; import { seedDatabase } from './seed'; @@ -14,6 +15,7 @@ export class StockFillDB extends Dexie { pickLists!: Table; pickItems!: Table; categories!: Table; + importExportLogs!: Table; constructor() { super('stockfill'); @@ -234,6 +236,17 @@ export class StockFillDB extends Dexie { }), ); }); + + this.version(8).stores({ + products: 'id, name, category, &barcode, archived, created_at, updated_at', + areas: 'id, name, created_at, updated_at', + pickLists: + 'id, area_id, created_at, completed_at, auto_add_new_products, categories', + pickItems: + 'id, pick_list_id, product_id, status, is_carton, quantity, created_at, updated_at', + categories: 'id, name, created_at, updated_at', + importExportLogs: 'id, type, timestamp', + }); } } diff --git a/src/models/ImportExportLog.ts b/src/models/ImportExportLog.ts new file mode 100644 index 0000000..94455c6 --- /dev/null +++ b/src/models/ImportExportLog.ts @@ -0,0 +1,18 @@ +export type ImportExportType = 'import' | 'export'; + +export interface ImportExportLogSummary { + inserted: number; + updated: number; + skipped: number; + errors: number; +} + +export interface ImportExportLog { + id: string; + type: ImportExportType; + timestamp: number; + selectedTypes: string[]; + summary: ImportExportLogSummary; + details: string[]; + fileNames?: string[]; +} diff --git a/src/screens/ImportExportScreen.tsx b/src/screens/ImportExportScreen.tsx new file mode 100644 index 0000000..6f9a40b --- /dev/null +++ b/src/screens/ImportExportScreen.tsx @@ -0,0 +1,225 @@ +import { + Box, + Button, + Card, + CardContent, + Checkbox, + Divider, + FormControlLabel, + Grid, + List, + ListItem, + ListItemText, + Stack, + Typography, +} from '@mui/material'; +import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { liveQuery } from 'dexie'; +import { useDatabase } from '../context/DBProvider'; +import { downloadLog, exportData, importFiles } from '../services/importExportService'; +import { DataType, ImportOptions } from '../services/importExportService'; +import { ImportExportLog } from '../models/ImportExportLog'; + +const templateFiles: { label: string; file: string }[] = [ + { label: 'Areas', file: 'areas_template.csv' }, + { label: 'Categories', file: 'categories_template.csv' }, + { label: 'Products', file: 'products_template.csv' }, + { label: 'Pick Lists', file: 'picklists_template.csv' }, + { label: 'Pick Items', file: 'pickitems_template.csv' }, +]; + +const dataTypes: { key: DataType; label: string }[] = [ + { key: 'areas', label: 'Areas' }, + { key: 'categories', label: 'Categories' }, + { key: 'products', label: 'Products' }, + { key: 'pick-lists', label: 'Pick Lists' }, + { key: 'pick-items', label: 'Pick Items' }, +]; + +export const ImportExportScreen = () => { + const db = useDatabase(); + const [selectedTypes, setSelectedTypes] = useState(dataTypes.map((d) => d.key)); + const [logLines, setLogLines] = useState([]); + const [history, setHistory] = useState([]); + const [allowAutoCreateMissing, setAllowAutoCreateMissing] = useState(true); + const fileInputRef = useRef(null); + + const appendLog = (line: string) => setLogLines((prev) => [...prev, line]); + + useEffect(() => { + const subscription = liveQuery(() => db.importExportLogs.orderBy('timestamp').reverse().toArray()).subscribe({ + next: (logs) => setHistory(logs), + }); + return () => subscription.unsubscribe(); + }, [db]); + + const handleToggleType = (key: DataType) => { + setSelectedTypes((prev) => (prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key])); + }; + + const handleSelectAll = () => { + if (selectedTypes.length === dataTypes.length) { + setSelectedTypes([]); + } else { + setSelectedTypes(dataTypes.map((d) => d.key)); + } + }; + + const handleExport = async () => { + setLogLines([]); + try { + await exportData(db, selectedTypes, appendLog); + appendLog('Export complete'); + } catch (error) { + appendLog(`Export failed: ${(error as Error).message}`); + } + }; + + const handleFileChange = (event: ChangeEvent) => { + const files = event.target.files; + if (files && files.length > 0) { + appendLog(`Selected ${files.length} file(s) for import`); + } + }; + + const handleImport = async () => { + setLogLines([]); + const files = fileInputRef.current?.files; + if (!files || files.length === 0) { + appendLog('Please select files to import'); + return; + } + const importOptions: ImportOptions = { + allowAutoCreateMissing, + }; + try { + await importFiles(db, Array.from(files), importOptions, appendLog); + appendLog('Import complete'); + } catch (error) { + appendLog(`Import failed: ${(error as Error).message}`); + } + }; + + const latestLog = useMemo(() => history[0], [history]); + + return ( + + + Import / Export Data + + + + + + + Templates + + + {templateFiles.map((template) => ( + + ))} + + + + + + + + + Data Types + + + } + label="Select all" + /> + + {dataTypes.map((type) => ( + handleToggleType(type.key)} />} + label={type.label} + /> + ))} + + + + + + + + + Actions + + + + + + setAllowAutoCreateMissing(event.target.checked)} + /> + } + label="Allow auto-create missing referenced entities" + /> + + + + + + + + + + Log + + + {logLines.length === 0 ? 'No log entries yet' : logLines.join('\n')} + + + + + + History + + {history.map((item) => ( + + + + + ))} + {history.length === 0 && No past logs} + + + + + + ); +}; diff --git a/src/services/importExportService.test.ts b/src/services/importExportService.test.ts new file mode 100644 index 0000000..5916a1e --- /dev/null +++ b/src/services/importExportService.test.ts @@ -0,0 +1,162 @@ +import JSZip from 'jszip'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { exportData, importFiles } from './importExportService'; +import { Product } from '../models/Product'; +import { Category } from '../models/Category'; +import { Area } from '../models/Area'; +import { PickList } from '../models/PickList'; +import { PickItem } from '../models/PickItem'; +import { StockFillDB } from '../db'; + +class MockTable { + constructor(public items: T[] = []) {} + + async toArray() { + return [...this.items]; + } + + async add(item: T) { + this.items.push(item); + return item.id; + } + + async get(id: string) { + return this.items.find((item) => item.id === id); + } +} + +const createMockDb = (data?: { + products?: Product[]; + categories?: Category[]; + areas?: Area[]; + pickLists?: PickList[]; + pickItems?: PickItem[]; +}) => { + const db = { + products: new MockTable(data?.products ?? []), + categories: new MockTable(data?.categories ?? []), + areas: new MockTable(data?.areas ?? []), + pickLists: new MockTable(data?.pickLists ?? []), + pickItems: new MockTable(data?.pickItems ?? []), + importExportLogs: new MockTable([]), + transaction: async (_mode: string, ...args: any[]) => { + const callback = args[args.length - 1]; + return callback(); + }, + } as unknown as StockFillDB; + + return db; +}; + +const stubDownloads = () => { + const anchor = { href: '', download: '', click: vi.fn() } as unknown as HTMLAnchorElement; + const createObjectURL = vi.fn(() => 'blob:url'); + const revokeObjectURL = vi.fn(); + // @ts-expect-error jsdom stub + vi.stubGlobal('document', { createElement: () => anchor }); + // @ts-expect-error jsdom stub + vi.stubGlobal('URL', { createObjectURL, revokeObjectURL }); + return { anchor, createObjectURL, revokeObjectURL }; +}; + +describe('import/export service', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('exports multiple types into a zip with friendly values', async () => { + const db = createMockDb({ + products: [ + { + id: 'p1', + name: 'Shirt', + category: 'c1', + unit_type: 'unit', + bulk_name: 'carton', + barcode: '123', + archived: false, + created_at: 1, + updated_at: 2, + }, + ], + categories: [ + { id: 'c1', name: 'Clothing', created_at: 1, updated_at: 2 }, + ], + areas: [ + { id: 'a1', name: 'Area A', created_at: 1, updated_at: 2 }, + ], + pickLists: [ + { + id: 'l1', + area_id: 'a1', + created_at: 1, + completed_at: undefined, + notes: 'Morning', + categories: ['c1'], + auto_add_new_products: false, + }, + ], + pickItems: [ + { + id: 'i1', + pick_list_id: 'l1', + product_id: 'p1', + quantity: 1, + is_carton: false, + status: 'pending', + created_at: 1, + updated_at: 2, + }, + ], + }); + stubDownloads(); + + const result = await exportData(db, ['products', 'categories', 'pick-lists', 'pick-items']); + const zip = await JSZip.loadAsync(result.blob as any); + const productCsv = await zip.file('products.csv')!.async('string'); + const pickItemsCsv = await zip.file('pickitems.csv')!.async('string'); + + expect(productCsv).toContain('Clothing'); + expect(pickItemsCsv).toContain('Shirt'); + expect(db.importExportLogs.items).toHaveLength(1); + }); + + it('imports products while deduplicating by name and auto-creating categories', async () => { + const db = createMockDb({ + products: [ + { + id: 'existing', + name: 'Existing', + category: 'c1', + unit_type: 'unit', + bulk_name: 'carton', + barcode: '999', + archived: false, + created_at: 1, + updated_at: 1, + }, + ], + categories: [{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 1 }], + }); + stubDownloads(); + + const csvContent = [ + 'id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at', + 'p2,Existing,Clothing,unit,carton,123,false,,', + 'p3,New Shirt,New Category,unit,carton,124,false,,', + ].join('\n'); + const csv = { name: 'products.csv', text: async () => csvContent } as unknown as File; + + const result = await importFiles( + db, + [csv], + { allowAutoCreateMissing: true }, + () => undefined, + ); + + expect(result.log.summary.inserted).toBeGreaterThanOrEqual(1); + expect(db.products.items.find((p) => p.name === 'Existing')).toBeTruthy(); + expect(db.products.items.find((p) => p.name === 'New Shirt')).toBeTruthy(); + expect(db.categories.items.find((c) => c.name === 'New Category')).toBeTruthy(); + }); +}); diff --git a/src/services/importExportService.ts b/src/services/importExportService.ts new file mode 100644 index 0000000..47f1f70 --- /dev/null +++ b/src/services/importExportService.ts @@ -0,0 +1,575 @@ +import JSZip from 'jszip'; +import Papa from 'papaparse'; +import { v4 as uuidv4 } from 'uuid'; +import { StockFillDB } from '../db'; +import { ImportExportLog, ImportExportLogSummary } from '../models/ImportExportLog'; +import { Area } from '../models/Area'; +import { Category } from '../models/Category'; +import { PickItem, PickItemStatus } from '../models/PickItem'; +import { PickList } from '../models/PickList'; +import { Product, DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product'; + +export type DataType = 'areas' | 'categories' | 'products' | 'pick-lists' | 'pick-items'; + +export interface ExportResult { + fileName: string; + blob: Blob; + log: ImportExportLog; +} + +export interface ImportOptions { + allowAutoCreateMissing: boolean; +} + +export interface ImportResult { + log: ImportExportLog; +} + +interface ParsedFile { + name: string; + content: string; +} + +const typeHints: Record = { + areas: ['area'], + categories: ['category'], + products: ['product'], + 'pick-lists': ['picklist', 'pick-list'], + 'pick-items': ['pickitem', 'pick-item'], +}; + +export const normalizeName = (value?: string) => + (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim(); + +const inferTypeFromName = (name: string): DataType | undefined => { + const lowercase = name.toLowerCase(); + return (Object.keys(typeHints) as DataType[]).find((key) => + typeHints[key].some((hint) => lowercase.includes(hint)), + ); +}; + +const parseCsv = async (content: string) => { + const result = Papa.parse>(content, { + header: true, + skipEmptyLines: true, + transformHeader: (header) => header.trim().toLowerCase(), + }); + + if (result.errors.length > 0) { + throw new Error(result.errors[0].message); + } + + return result.data; +}; + +const readFiles = async (files: File[]) => { + const parsed: ParsedFile[] = []; + for (const file of files) { + if (file.name.toLowerCase().endsWith('.zip')) { + const zip = await JSZip.loadAsync(file); + const entries = Object.values(zip.files).filter((entry) => !entry.dir); + // eslint-disable-next-line no-await-in-loop + for (const entry of entries) { + const content = await entry.async('string'); + parsed.push({ name: entry.name, content }); + } + continue; + } + + const content = await file.text(); + parsed.push({ name: file.name, content }); + } + return parsed; +}; + +const createLog = ( + type: ImportExportLog['type'], + selectedTypes: string[], + summary: ImportExportLogSummary, + details: string[], + fileNames?: string[], +): ImportExportLog => ({ + id: uuidv4(), + type, + timestamp: Date.now(), + selectedTypes, + summary, + details, + fileNames, +}); + +const serializeCsv = (rows: object[]) => Papa.unparse(rows, { quotes: true }); + +const triggerDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +}; + +export const exportData = async ( + db: StockFillDB, + selectedTypes: DataType[], + onLog?: (line: string) => void, +): Promise => { + if (selectedTypes.length === 0) { + throw new Error('No data types selected'); + } + + const details: string[] = []; + const fileEntries: { name: string; content: string }[] = []; + const categories = await db.categories.toArray(); + const categoriesById = new Map(categories.map((c) => [c.id, c.name])); + const areas = await db.areas.toArray(); + const areasById = new Map(areas.map((a) => [a.id, a.name])); + let inserted = 0; + + const addDetail = (line: string) => { + details.push(line); + onLog?.(line); + }; + + if (selectedTypes.includes('areas')) { + const rows = areas.map((area) => ({ + id: area.id, + name: area.name, + created_at: area.created_at, + updated_at: area.updated_at, + })); + inserted += rows.length; + addDetail(`Exported ${rows.length} areas`); + fileEntries.push({ name: 'areas.csv', content: serializeCsv(rows) }); + } + + if (selectedTypes.includes('categories')) { + const rows = categories.map((category) => ({ + id: category.id, + name: category.name, + created_at: category.created_at, + updated_at: category.updated_at, + })); + inserted += rows.length; + addDetail(`Exported ${rows.length} categories`); + fileEntries.push({ name: 'categories.csv', content: serializeCsv(rows) }); + } + + if (selectedTypes.includes('products')) { + const products = await db.products.toArray(); + const rows = products.map((product) => ({ + id: product.id, + name: product.name, + category: categoriesById.get(product.category) ?? '', + unit_type: product.unit_type, + bulk_name: product.bulk_name, + barcode: product.barcode, + archived: product.archived, + created_at: product.created_at, + updated_at: product.updated_at, + })); + inserted += rows.length; + addDetail(`Exported ${rows.length} products`); + fileEntries.push({ name: 'products.csv', content: serializeCsv(rows) }); + } + + if (selectedTypes.includes('pick-lists')) { + const pickLists = await db.pickLists.toArray(); + const rows = pickLists.map((list) => ({ + id: list.id, + area_id: list.area_id, + area_name: areasById.get(list.area_id) ?? '', + created_at: list.created_at, + completed_at: list.completed_at ?? '', + notes: list.notes ?? '', + categories: (list.categories ?? []) + .map((id) => categoriesById.get(id) ?? '') + .filter(Boolean) + .join(';'), + auto_add_new_products: list.auto_add_new_products, + })); + inserted += rows.length; + addDetail(`Exported ${rows.length} pick lists`); + fileEntries.push({ name: 'picklists.csv', content: serializeCsv(rows) }); + } + + if (selectedTypes.includes('pick-items')) { + const pickItems = await db.pickItems.toArray(); + const pickLists = await db.pickLists.toArray(); + const pickListById = new Map(pickLists.map((pl) => [pl.id, pl])); + const products = await db.products.toArray(); + const productById = new Map(products.map((p) => [p.id, p])); + const rows = pickItems.map((item) => { + const list = pickListById.get(item.pick_list_id); + const product = productById.get(item.product_id); + return { + id: item.id, + pick_list_id: item.pick_list_id, + pick_list_name: list?.notes ?? '', + product_id: item.product_id, + product_name: product?.name ?? '', + quantity: item.quantity, + is_carton: item.is_carton, + status: item.status, + created_at: item.created_at, + updated_at: item.updated_at, + }; + }); + inserted += rows.length; + addDetail(`Exported ${rows.length} pick items`); + fileEntries.push({ name: 'pickitems.csv', content: serializeCsv(rows) }); + } + + let blob: Blob; + let fileName: string; + if (fileEntries.length === 1) { + blob = new Blob([fileEntries[0].content], { type: 'text/csv;charset=utf-8' }); + fileName = fileEntries[0].name; + } else { + const zip = new JSZip(); + fileEntries.forEach((file) => { + zip.file(file.name, file.content); + }); + blob = await zip.generateAsync({ type: 'blob' }); + fileName = `stockfill_export_${new Date().toISOString().slice(0, 10)}.zip`; + } + + const log = createLog( + 'export', + selectedTypes, + { inserted, updated: 0, skipped: 0, errors: 0 }, + details, + fileEntries.map((f) => f.name), + ); + await db.importExportLogs.add(log); + + triggerDownload(blob, fileName); + + return { fileName, blob, log }; +}; + +const addOrUpdateMap = (map: Map, key: string, value: string) => { + if (!map.has(key)) { + map.set(key, value); + } +}; + +const coerceBoolean = (value: string | boolean | undefined) => { + if (typeof value === 'boolean') return value; + if (!value) return false; + return value.toString().toLowerCase() === 'true'; +}; + +const coerceNumber = (value: string | number | undefined) => { + if (typeof value === 'number') return value; + const parsed = Number(value ?? ''); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +export const importFiles = async ( + db: StockFillDB, + files: File[], + options: ImportOptions, + onLog?: (line: string) => void, +): Promise => { + const parsedFiles = await readFiles(files); + const details: string[] = []; + const selectedTypes: DataType[] = []; + const addDetail = (line: string) => { + details.push(line); + onLog?.(line); + }; + + const parsedRows: Partial[]>> = {}; + + for (const file of parsedFiles) { + const type = inferTypeFromName(file.name); + if (!type) { + addDetail(`Skipped ${file.name}: could not infer data type`); + continue; + } + // eslint-disable-next-line no-await-in-loop + const rows = await parseCsv(file.content); + parsedRows[type] = rows; + selectedTypes.push(type); + addDetail(`Parsed ${rows.length} rows from ${file.name}`); + } + + let inserted = 0; + let skipped = 0; + let errors = 0; + + const now = Date.now(); + const areaNameToId = new Map(); + const categoryNameToId = new Map(); + const productNameToId = new Map(); + const barcodeToProductId = new Map(); + const pickListNameToId = new Map(); + + const existingAreas = await db.areas.toArray(); + existingAreas.forEach((area) => addOrUpdateMap(areaNameToId, normalizeName(area.name), area.id)); + + const existingCategories = await db.categories.toArray(); + existingCategories.forEach((cat) => addOrUpdateMap(categoryNameToId, normalizeName(cat.name), cat.id)); + + const existingProducts = await db.products.toArray(); + existingProducts.forEach((product) => { + addOrUpdateMap(productNameToId, normalizeName(product.name), product.id); + if (product.barcode) { + addOrUpdateMap(barcodeToProductId, product.barcode, product.id); + } + }); + + const existingPickLists = await db.pickLists.toArray(); + existingPickLists.forEach((list) => { + if (list.notes) { + addOrUpdateMap(pickListNameToId, normalizeName(list.notes), list.id); + } + }); + + try { + await db.transaction( + 'rw', + db.areas, + db.categories, + db.products, + db.pickLists, + db.pickItems, + async () => { + const parseTimestamp = (value?: string) => + value && value.trim() !== '' ? Number(value) || now : now; + + // Areas + if (parsedRows['areas']) { + for (const row of parsedRows['areas']) { + const name = normalizeName(row.name); + if (!name) { + addDetail('Skipped area with empty name'); + skipped += 1; + continue; + } + if (areaNameToId.has(name)) { + addDetail(`Area "${row.name}" exists, skipping`); + skipped += 1; + continue; + } + const id = row.id && !(await db.areas.get(row.id)) ? row.id : uuidv4(); + const area: Area = { + id, + name: row.name.trim(), + created_at: parseTimestamp(row.created_at), + updated_at: parseTimestamp(row.updated_at), + }; + await db.areas.add(area); + areaNameToId.set(name, id); + inserted += 1; + addDetail(`Created area "${area.name}"`); + } + } + + // Categories + if (parsedRows['categories']) { + for (const row of parsedRows['categories']) { + const name = normalizeName(row.name); + if (!name) { + addDetail('Skipped category with empty name'); + skipped += 1; + continue; + } + if (categoryNameToId.has(name)) { + addDetail(`Category "${row.name}" exists, skipping`); + skipped += 1; + continue; + } + const id = row.id && !(await db.categories.get(row.id)) ? row.id : uuidv4(); + const category: Category = { + id, + name: row.name.trim(), + created_at: parseTimestamp(row.created_at), + updated_at: parseTimestamp(row.updated_at), + }; + await db.categories.add(category); + categoryNameToId.set(name, id); + inserted += 1; + addDetail(`Created category "${category.name}"`); + } + } + + // Products + if (parsedRows['products']) { + for (const row of parsedRows['products']) { + const name = normalizeName(row.name); + if (!name) { + addDetail('Skipped product with empty name'); + skipped += 1; + continue; + } + if (productNameToId.has(name)) { + addDetail(`Product "${row.name}" exists, skipping`); + skipped += 1; + continue; + } + + const categoryName = normalizeName(row.category); + let categoryId = categoryName ? categoryNameToId.get(categoryName) : undefined; + if (!categoryId && categoryName) { + if (options.allowAutoCreateMissing) { + const newId = uuidv4(); + categoryId = newId; + categoryNameToId.set(categoryName, newId); + await db.categories.add({ + id: newId, + name: row.category.trim(), + created_at: now, + updated_at: now, + }); + addDetail(`Auto-created category "${row.category}" for product "${row.name}"`); + } else { + addDetail(`Missing category for product "${row.name}", skipping`); + skipped += 1; + continue; + } + } + + const barcode = row.barcode?.trim(); + if (barcode && barcodeToProductId.has(barcode)) { + addDetail(`Barcode ${barcode} already exists, clearing for product "${row.name}"`); + } + + const id = row.id && !(await db.products.get(row.id)) ? row.id : uuidv4(); + const product: Product = { + id, + name: row.name.trim(), + category: categoryId ?? '', + unit_type: row.unit_type?.trim() || DEFAULT_UNIT_TYPE, + bulk_name: row.bulk_name?.trim() || DEFAULT_BULK_NAME, + barcode: barcode && !barcodeToProductId.has(barcode) ? barcode : undefined, + archived: coerceBoolean(row.archived), + created_at: parseTimestamp(row.created_at), + updated_at: parseTimestamp(row.updated_at), + }; + await db.products.add(product); + productNameToId.set(name, id); + if (product.barcode) { + barcodeToProductId.set(product.barcode, id); + } + inserted += 1; + addDetail(`Created product "${product.name}"`); + } + } + + // Pick lists + if (parsedRows['pick-lists']) { + for (const row of parsedRows['pick-lists']) { + const inferredAreaName = normalizeName(row.area_name); + const areaId = row.area_id || (inferredAreaName ? areaNameToId.get(inferredAreaName) : undefined); + if (!areaId && !options.allowAutoCreateMissing) { + addDetail(`Missing area for pick list, skipping`); + skipped += 1; + continue; + } + let resolvedAreaId = areaId; + if (!resolvedAreaId && inferredAreaName) { + const newAreaId = uuidv4(); + resolvedAreaId = newAreaId; + areaNameToId.set(inferredAreaName, newAreaId); + await db.areas.add({ + id: newAreaId, + name: row.area_name.trim(), + created_at: now, + updated_at: now, + }); + addDetail(`Auto-created area "${row.area_name}" for pick list`); + } + const categoriesFromRow = (row.categories || '') + .split(';') + .map((name) => normalizeName(name)) + .filter(Boolean); + const categoryIds: string[] = []; + for (const catName of categoriesFromRow) { + let catId = categoryNameToId.get(catName); + if (!catId && options.allowAutoCreateMissing) { + const newId = uuidv4(); + catId = newId; + categoryNameToId.set(catName, newId); + await db.categories.add({ + id: newId, + name: catName, + created_at: now, + updated_at: now, + }); + addDetail(`Auto-created category "${catName}" for pick list`); + } + if (catId) categoryIds.push(catId); + } + + const id = row.id && !(await db.pickLists.get(row.id)) ? row.id : uuidv4(); + const pickList: PickList = { + id, + area_id: resolvedAreaId ?? uuidv4(), + created_at: parseTimestamp(row.created_at), + completed_at: row.completed_at ? Number(row.completed_at) : undefined, + notes: row.notes ?? '', + categories: categoryIds, + auto_add_new_products: coerceBoolean(row.auto_add_new_products), + }; + await db.pickLists.add(pickList); + if (pickList.notes) { + pickListNameToId.set(normalizeName(pickList.notes), pickList.id); + } + inserted += 1; + addDetail(`Created pick list ${pickList.id}`); + } + } + + // Pick items + if (parsedRows['pick-items']) { + for (const row of parsedRows['pick-items']) { + const pickListId = + row.pick_list_id || pickListNameToId.get(normalizeName(row.pick_list_name)); + const productId = row.product_id || productNameToId.get(normalizeName(row.product_name)); + + if (!pickListId || !productId) { + addDetail(`Missing pick list or product for pick item, skipping`); + skipped += 1; + continue; + } + + const id = row.id && !(await db.pickItems.get(row.id)) ? row.id : uuidv4(); + const pickItem: PickItem = { + id, + pick_list_id: pickListId, + product_id: productId, + quantity: coerceNumber(row.quantity) || 1, + is_carton: coerceBoolean(row.is_carton), + status: (row.status as PickItemStatus) || 'pending', + created_at: parseTimestamp(row.created_at), + updated_at: parseTimestamp(row.updated_at), + }; + await db.pickItems.add(pickItem); + inserted += 1; + addDetail(`Added pick item for product ${row.product_name ?? pickItem.product_id}`); + } + } + }, + ); + } catch (error) { + errors += 1; + addDetail(`Import failed: ${(error as Error).message}`); + } + + const log = createLog( + 'import', + selectedTypes, + { inserted, updated: 0, skipped, errors }, + details, + parsedFiles.map((f) => f.name), + ); + await db.importExportLogs.add(log); + return { log }; +}; + +export const downloadLog = (log: ImportExportLog) => { + const blob = new Blob([JSON.stringify(log, null, 2)], { type: 'application/json' }); + triggerDownload(blob, `stockfill-${log.type}-log-${log.timestamp}.json`); +};