Add import/export tooling and templates

This commit is contained in:
beatz174-bit committed 2025-11-25 08:40:10 +10:00
1 parent 5ddd41ec24
commit dc7fc02d59
15 files changed
+1138

No files matched your search

+2
View File
@@ -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 = () => {
<Route path="/categories" element={<ManageCategoriesScreen />} />
<Route path="/areas" element={<ManageAreasScreen />} />
<Route path="/scan" element={<BarcodeScannerScreen />} />
<Route path="/data" element={<ImportExportScreen />} />
</Route>
</Routes>
);
+1
View File
@@ -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' },
];
+13
View File
@@ -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<PickList>;
pickItems!: Table<PickItem>;
categories!: Table<Category>;
importExportLogs!: Table<ImportExportLog>;
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',
});
}
}
+18
View File
@@ -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[];
}
+225
View File
@@ -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>
);
};
+162
View File
@@ -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();
});
});
+575
View File
@@ -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`);
};