Add import/export tooling and templates
This commit is contained in:
Generated
+107
@@ -16,6 +16,8 @@
|
|||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
@@ -3269,6 +3271,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@@ -4168,6 +4176,12 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4204,6 +4218,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -4282,6 +4302,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4454,6 +4480,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4478,6 +4516,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"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"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -5040,6 +5099,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -5179,6 +5244,21 @@
|
|||||||
"react-dom": ">=16.6.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -5326,6 +5406,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"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": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"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==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5479,6 +5571,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -5873,6 +5974,12 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,created_at,updated_at
|
||||||
|
# id optional — you can use empty for new rows. created/updated optional
|
||||||
|
Area A,,
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
id,name,created_at,updated_at
|
||||||
|
Clothing,,
|
||||||
|
@@ -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,,
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
id,area_id,area_name,created_at,completed_at,notes,categories,auto_add_new_products
|
||||||
|
,,"Area A",,,"","Clothing;Accessories",false
|
||||||
|
@@ -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,,
|
||||||
|
@@ -11,6 +11,7 @@ import { ManageAreasScreen } from './screens/ManageAreasScreen';
|
|||||||
import { ManageCategoriesScreen } from './screens/ManageCategoriesScreen';
|
import { ManageCategoriesScreen } from './screens/ManageCategoriesScreen';
|
||||||
import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen';
|
import { BarcodeScannerScreen } from './screens/BarcodeScannerScreen';
|
||||||
import { useServiceWorker } from './hooks/useServiceWorker';
|
import { useServiceWorker } from './hooks/useServiceWorker';
|
||||||
|
import { ImportExportScreen } from './screens/ImportExportScreen';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
@@ -34,6 +35,7 @@ const AppRoutes = () => {
|
|||||||
<Route path="/categories" element={<ManageCategoriesScreen />} />
|
<Route path="/categories" element={<ManageCategoriesScreen />} />
|
||||||
<Route path="/areas" element={<ManageAreasScreen />} />
|
<Route path="/areas" element={<ManageAreasScreen />} />
|
||||||
<Route path="/scan" element={<BarcodeScannerScreen />} />
|
<Route path="/scan" element={<BarcodeScannerScreen />} />
|
||||||
|
<Route path="/data" element={<ImportExportScreen />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const navLinks = [
|
|||||||
{ to: '/products', label: 'Products' },
|
{ to: '/products', label: 'Products' },
|
||||||
{ to: '/categories', label: 'Categories' },
|
{ to: '/categories', label: 'Categories' },
|
||||||
{ to: '/areas', label: 'Areas' },
|
{ to: '/areas', label: 'Areas' },
|
||||||
|
{ to: '/data', label: 'Import/Export' },
|
||||||
{ to: '/scan', label: 'Scan' },
|
{ to: '/scan', label: 'Scan' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Category } from '../models/Category';
|
|||||||
import { PickItem } from '../models/PickItem';
|
import { PickItem } from '../models/PickItem';
|
||||||
import { PickList } from '../models/PickList';
|
import { PickList } from '../models/PickList';
|
||||||
import { Product } from '../models/Product';
|
import { Product } from '../models/Product';
|
||||||
|
import { ImportExportLog } from '../models/ImportExportLog';
|
||||||
import { applyMigrations } from './migrations';
|
import { applyMigrations } from './migrations';
|
||||||
import { seedDatabase } from './seed';
|
import { seedDatabase } from './seed';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export class StockFillDB extends Dexie {
|
|||||||
pickLists!: Table<PickList>;
|
pickLists!: Table<PickList>;
|
||||||
pickItems!: Table<PickItem>;
|
pickItems!: Table<PickItem>;
|
||||||
categories!: Table<Category>;
|
categories!: Table<Category>;
|
||||||
|
importExportLogs!: Table<ImportExportLog>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('stockfill');
|
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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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<DataType[]>(dataTypes.map((d) => d.key));
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
const [history, setHistory] = useState<ImportExportLog[]>([]);
|
||||||
|
const [allowAutoCreateMissing, setAllowAutoCreateMissing] = useState(true);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Box p={2}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Import / Export Data
|
||||||
|
</Typography>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Templates
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{templateFiles.map((template) => (
|
||||||
|
<Button key={template.file} variant="outlined" component="a" href={`/templates/${template.file}`} download>
|
||||||
|
Download {template.label} Template
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={5}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Data Types
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={selectedTypes.length === dataTypes.length} onChange={handleSelectAll} />}
|
||||||
|
label="Select all"
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
{dataTypes.map((type) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={type.key}
|
||||||
|
control={<Checkbox checked={selectedTypes.includes(type.key)} onChange={() => handleToggleType(type.key)} />}
|
||||||
|
label={type.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Actions
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Button variant="contained" disabled={selectedTypes.length === 0} onClick={handleExport}>
|
||||||
|
Export Selected
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" component="label">
|
||||||
|
Select CSV or ZIP
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv,.zip" multiple hidden onChange={handleFileChange} />
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" color="secondary" onClick={handleImport}>
|
||||||
|
Import Files
|
||||||
|
</Button>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={allowAutoCreateMissing}
|
||||||
|
onChange={(event) => setAllowAutoCreateMissing(event.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Allow auto-create missing referenced entities"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Box mt={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Log
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{ backgroundColor: '#f6f6f6', p: 2, borderRadius: 1, maxHeight: 240, overflow: 'auto' }}
|
||||||
|
>
|
||||||
|
{logLines.length === 0 ? 'No log entries yet' : logLines.join('\n')}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} mt={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!latestLog}
|
||||||
|
onClick={() => {
|
||||||
|
if (latestLog) downloadLog(latestLog);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download latest log
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle1">History</Typography>
|
||||||
|
<List dense>
|
||||||
|
{history.map((item) => (
|
||||||
|
<ListItem key={item.id} divider>
|
||||||
|
<ListItemText
|
||||||
|
primary={`${new Date(item.timestamp).toLocaleString()} — ${item.type}`}
|
||||||
|
secondary={`Inserted: ${item.summary.inserted}, Skipped: ${item.summary.skipped}, Errors: ${item.summary.errors}`}
|
||||||
|
/>
|
||||||
|
<Button size="small" onClick={() => downloadLog(item)}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{history.length === 0 && <ListItem>No past logs</ListItem>}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<T extends { id: string }> {
|
||||||
|
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<Product>(data?.products ?? []),
|
||||||
|
categories: new MockTable<Category>(data?.categories ?? []),
|
||||||
|
areas: new MockTable<Area>(data?.areas ?? []),
|
||||||
|
pickLists: new MockTable<PickList>(data?.pickLists ?? []),
|
||||||
|
pickItems: new MockTable<PickItem>(data?.pickItems ?? []),
|
||||||
|
importExportLogs: new MockTable<any>([]),
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DataType, string[]> = {
|
||||||
|
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<Record<string, string>>(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<ExportResult> => {
|
||||||
|
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<string, string>, 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<ImportResult> => {
|
||||||
|
const parsedFiles = await readFiles(files);
|
||||||
|
const details: string[] = [];
|
||||||
|
const selectedTypes: DataType[] = [];
|
||||||
|
const addDetail = (line: string) => {
|
||||||
|
details.push(line);
|
||||||
|
onLog?.(line);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedRows: Partial<Record<DataType, Record<string, string>[]>> = {};
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
const categoryNameToId = new Map<string, string>();
|
||||||
|
const productNameToId = new Map<string, string>();
|
||||||
|
const barcodeToProductId = new Map<string, string>();
|
||||||
|
const pickListNameToId = new Map<string, string>();
|
||||||
|
|
||||||
|
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`);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user