diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml new file mode 100644 index 0000000..7b0bd06 --- /dev/null +++ b/.github/workflows/pr-review.yml @@ -0,0 +1,96 @@ +name: PR Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + review: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Generate Marty token + id: marty-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.MARTY_APP_ID }} + private-key: ${{ secrets.MARTY_APP_PRIVATE_KEY }} + + - name: Run Marty PR Review + uses: nesalia-inc/marty-action@1.0.0 + with: + github_token: ${{ steps.marty-token.outputs.token }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + TITLE: ${{ github.event.pull_request.title }} + + Please review this pull request comprehensively: + + ## Review Focus Areas + + ### Code Quality + - Code follows best practices and design patterns + - Proper error handling and edge cases + - No code duplication (DRY principle) + - Clear and maintainable code structure + + ### Security + - No hardcoded secrets or credentials + - Proper input validation and sanitization + - SQL injection and XSS vulnerabilities + - Authentication and authorization checks + - Sensitive data handling + + ### Performance + - Potential performance bottlenecks + - Efficient database queries + - Proper caching strategies + - Resource cleanup and memory leaks + + ### Testing + - Adequate test coverage for changes + - Edge cases covered + - Test quality and assertions + + ### Documentation + - README updated if needed + - Inline comments for complex logic + - API documentation updated + - Breaking changes documented + + ## Review Output Format + + Use inline comments for specific code issues (highlight exact lines). + Use top-level PR comments for general observations and summary. + + Structure your top-level comment as: + - **Summary**: Brief overview + - **Critical Issues**: Must-fix items + - **Recommendations**: Suggestions for improvement + - **Positive Notes**: Good practices observed + + Note: The PR branch is already checked out in the current working directory. + + Only post GitHub comments - don't submit review text as messages. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" + --max-turns 100 + + env: + ANTHROPIC_BASE_URL: https://api.minimax.io/anthropic + ANTHROPIC_AUTH_TOKEN: ${{ secrets.MINIMAX_API_KEY }} + ANTHROPIC_DEFAULT_SONNET_MODEL: MiniMax-M2.5 + ANTHROPIC_DEFAULT_HAIKU_MODEL: MiniMax-M2.5 + ANTHROPIC_DEFAULT_OPUS_MODEL: MiniMax-M2.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cbad707 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build outputs +dist/ +out/ +build/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Environment +.env +.env.local +.env.*.local + +# Testing +coverage/ + +# Turbo +.turbo/ + +# Misc +*.tsbuildinfo diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index ac341ad..384aee0 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' +import tailwindcss from '@tailwindcss/vite' +import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ main: { @@ -23,13 +25,21 @@ export default defineConfig({ } }, renderer: { - root: resolve(__dirname, '../web/dist'), + root: resolve(__dirname, '../web'), + plugins: [ + tailwindcss(), + tsconfigPaths({ projects: ['./tsconfig.json'] }) + ], build: { rollupOptions: { input: { - index: resolve(__dirname, '../web/dist/index.html') + index: resolve(__dirname, '../web/index.html') } } + }, + server: { + host: '127.0.0.1', + port: 3000 } } }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7ed2b1b..ff8559f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,11 +23,14 @@ "@electron-toolkit/eslint-config": "^1.0.2", "@electron-toolkit/eslint-config-ts": "^2.0.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@tailwindcss/vite": "^4.1.18", "@types/node": "^22.10.2", "electron": "^33.3.1", "electron-builder": "^25.1.8", - "electron-vite": "^2.3.0", + "electron-vite": "^5.0.0", + "tailwindcss": "^4.1.18", "typescript": "^5.7.2", - "vite": "^7.1.7" + "vite": "^7.1.7", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/apps/web/index.html b/apps/web/index.html index 0788391..613bd99 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,10 +1,15 @@ - - web + + Wareflow +
diff --git a/apps/web/package.json b/apps/web/package.json index 1bacf56..23d7033 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@tanstack/react-router": "^1.132.0", "@tanstack/react-router-devtools": "^1.132.0", "@tanstack/router-plugin": "^1.132.0", + "@wareflow/db": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -27,6 +28,7 @@ "input-otp": "^1.4.2", "lucide-react": "^0.545.0", "next-themes": "^0.4.6", + "papaparse": "^5.5.3", "radix-ui": "^1.4.3", "react": "^19.2.0", "react-day-picker": "^9.13.2", @@ -46,6 +48,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/node": "^22.10.2", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", diff --git a/apps/web/src/components/setup/column-mapping.tsx b/apps/web/src/components/setup/column-mapping.tsx new file mode 100644 index 0000000..5966f76 --- /dev/null +++ b/apps/web/src/components/setup/column-mapping.tsx @@ -0,0 +1,165 @@ +import { useMemo } from 'react' +import { ArrowLeft, ArrowRight, RefreshCw, Columns2 } from 'lucide-react' +import { Button } from '../ui/button' +import { IMPORT_FIELDS, type ColumnMapping, type ImportField, type ParsedData } from '../../types/setup' + +interface ColumnMappingProps { + data: ParsedData[] + headers: string[] + columnMapping: ColumnMapping + onBack: () => void + onNext: () => void + onMappingChange: (mapping: ColumnMapping) => void +} + +export function ColumnMappingComponent({ + data, + headers, + columnMapping, + onBack, + onNext, + onMappingChange, +}: ColumnMappingProps) { + const sampleData = useMemo(() => data.slice(0, 3), [data]) + + const autoDetectMapping = useMemo(() => { + const mapping: ColumnMapping = {} + + IMPORT_FIELDS.forEach(field => { + // Try exact match first + const exactMatch = headers.find( + h => h.toLowerCase() === field.label.toLowerCase() || + h.toLowerCase() === field.key.toLowerCase() + ) + + if (exactMatch) { + mapping[field.key] = exactMatch + return + } + + // Try partial match + const partialMatch = headers.find( + h => h.toLowerCase().includes(field.key.toLowerCase()) || + field.label.toLowerCase().includes(h.toLowerCase()) + ) + + if (partialMatch) { + mapping[field.key] = partialMatch + } + }) + + return mapping + }, [headers]) + + const handleReset = () => { + onMappingChange(autoDetectMapping) + } + + const handleHeaderChange = (fieldKey: ImportField['key'], header: string) => { + onMappingChange({ ...columnMapping, [fieldKey]: header }) + } + + const mappedCount = Object.keys(columnMapping).filter(k => columnMapping[k as ImportField['key']]).length + const requiredMapped = IMPORT_FIELDS + .filter(f => f.required) + .every(f => columnMapping[f.key]) + + return ( +
+
+

+ Map Your Columns +

+

+ Match your file columns to the system fields +

+
+ +
+
+ + + {mappedCount} / {IMPORT_FIELDS.length} fields mapped + +
+ +
+ +
+
+
+ File Columns +
+
+ System Fields +
+
+ +
+ {IMPORT_FIELDS.map((field) => ( +
+
+
+ {field.label} + {field.required && *} +
+ {field.defaultValue && ( +
+ Default: {field.defaultValue} +
+ )} +
+ +
+ ))} +
+
+ + {/* Sample data preview */} +
+
+ Sample Data Preview +
+
+ {IMPORT_FIELDS.slice(0, 3).map((field) => { + const header = columnMapping[field.key] + const sample = header ? sampleData[0]?.[header] : null + return ( +
+
{field.label}
+
+ {sample || '-'} +
+
+ ) + })} +
+
+ +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/setup/complete.tsx b/apps/web/src/components/setup/complete.tsx new file mode 100644 index 0000000..e23a20e --- /dev/null +++ b/apps/web/src/components/setup/complete.tsx @@ -0,0 +1,54 @@ +import { CheckCircle, Package, Upload, ArrowRight } from 'lucide-react' +import { Button } from '../ui/button' + +interface CompleteProps { + importedCount: number + onViewProducts: () => void + onImportAnother: () => void +} + +export function Complete({ importedCount, onViewProducts, onImportAnother }: CompleteProps) { + return ( +
+
+
+ +
+ +

+ Import Complete! +

+

+ Your products have been imported successfully +

+
+ +
+
+ +
+
+ {importedCount} +
+
+ Products Imported +
+
+
+
+ +
+ + + +
+
+ ) +} diff --git a/apps/web/src/components/setup/file-selection.tsx b/apps/web/src/components/setup/file-selection.tsx new file mode 100644 index 0000000..9344e47 --- /dev/null +++ b/apps/web/src/components/setup/file-selection.tsx @@ -0,0 +1,181 @@ +import { useCallback, useState } from 'react' +import { Upload, FileSpreadsheet, AlertCircle, FileText } from 'lucide-react' +import Papa from 'papaparse' + +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB +const ACCEPTED_TYPES = ['.csv'] + +interface FileSelectionProps { + onFileSelect: (file: File, data: string[][], headers: string[]) => void +} + +export function FileSelection({ onFileSelect }: FileSelectionProps) { + const [isDragging, setIsDragging] = useState(false) + const [error, setError] = useState(null) + + const parseFile = useCallback(async (file: File) => { + setError(null) + + if (file.size > MAX_FILE_SIZE) { + setError(`File is too large. Maximum size is 10 MB.`) + return + } + + const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase() + if (!ACCEPTED_TYPES.includes(ext)) { + setError(`Invalid file type. Accepted: .csv`) + return + } + + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: (results) => { + if (results.errors.length > 0) { + setError(`Parsing error: ${results.errors[0].message}`) + return + } + + const headers = results.meta.fields || [] + if (headers.length === 0) { + setError('File has no headers') + return + } + + const data = results.data as Record[] + if (data.length === 0) { + setError('File is empty') + return + } + + // Convert to array of arrays for compatibility + const dataArray = data.map(row => headers.map(h => row[h] || '')) + onFileSelect(file, dataArray, headers) + }, + error: (err) => { + setError(`Failed to parse file: ${err.message}`) + } + }) + }, [onFileSelect]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + const file = e.dataTransfer.files[0] + if (file) { + parseFile(file) + } + }, [parseFile]) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleFileInput = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + parseFile(file) + } + }, [parseFile]) + + return ( +
+
+
+ +
+

+ Import Your Products +

+

+ Upload a CSV file with your product inventory +

+
+ +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + document.getElementById('file-input')?.click() + } + }} + className={` + relative border-2 border-dashed rounded-2xl p-16 text-center cursor-pointer + transition-all duration-300 outline-none + ${isDragging + ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 scale-[1.02]' + : 'border-slate-300 dark:border-slate-600 hover:border-slate-400 dark:hover:border-slate-500 hover:bg-slate-50 dark:hover:bg-slate-800/50 focus-visible:ring-4 focus-visible:ring-blue-500/20' + } + `} + > + + + +
+
+ {isDragging ? ( +
+ +
+

+ {isDragging ? 'Drop your file here' : 'Drag & drop your CSV file'} +

+

+ or click to browse +

+
+ +
+ .csv + Max 10 MB +
+
+
+ + {error && ( +
+
+ )} + + {/* Help text */} +
+

+ Your CSV should include columns like: SKU, Name, Quantity +

+
+
+ ) +} diff --git a/apps/web/src/components/setup/index.ts b/apps/web/src/components/setup/index.ts new file mode 100644 index 0000000..4a52ebb --- /dev/null +++ b/apps/web/src/components/setup/index.ts @@ -0,0 +1,5 @@ +export { FileSelection } from './file-selection' +export { Preview } from './preview' +export { ColumnMappingComponent } from './column-mapping' +export { Validation } from './validation' +export { Complete } from './complete' diff --git a/apps/web/src/components/setup/preview.tsx b/apps/web/src/components/setup/preview.tsx new file mode 100644 index 0000000..3dc02e9 --- /dev/null +++ b/apps/web/src/components/setup/preview.tsx @@ -0,0 +1,94 @@ +import { FileSpreadsheet, ArrowLeft, ArrowRight, Rows3 } from 'lucide-react' +import { Button } from '../ui/button' +import type { ParsedData } from '../../types/setup' + +interface PreviewProps { + data: ParsedData[] + headers: string[] + rowCount: number + fileName: string + onBack: () => void + onNext: () => void +} + +export function Preview({ data, headers, rowCount, fileName, onBack, onNext }: PreviewProps) { + const previewData = data.slice(0, 10) + + return ( +
+
+

+ Preview Your Data +

+

+ Showing first 10 rows from {rowCount} total rows +

+
+ +
+
+
+ + {fileName} +
+
+ + {rowCount} rows +
+
+ +
+ + + + + {headers.map((header) => ( + + ))} + + + + {previewData.map((row, idx) => ( + + + {headers.map((header) => ( + + ))} + + ))} + +
+ # + + {header} +
{idx + 1} + {row[header] || '-'} +
+
+ + {rowCount > 10 && ( +
+ ... and {rowCount - 10} more rows +
+ )} +
+ +
+ + +
+
+ ) +} diff --git a/apps/web/src/components/setup/validation.tsx b/apps/web/src/components/setup/validation.tsx new file mode 100644 index 0000000..5bbb320 --- /dev/null +++ b/apps/web/src/components/setup/validation.tsx @@ -0,0 +1,222 @@ +import { useMemo } from 'react' +import { AlertTriangle, CheckCircle, XCircle, ArrowLeft, ArrowRight, AlertCircle } from 'lucide-react' +import { Button } from '../ui/button' +import { IMPORT_FIELDS, type ColumnMapping, type ParsedData, type ValidationResult, type ValidationError, type ValidationWarning } from '../../types/setup' + +interface ValidationProps { + data: ParsedData[] + columnMapping: ColumnMapping + onBack: () => void + onImport: () => void + isImporting?: boolean + error?: string | null +} + +export function Validation({ data, columnMapping, onBack, onImport, isImporting = false, error }: ValidationProps) { + const validation = useMemo((): ValidationResult => { + const errors: ValidationError[] = [] + const warnings: ValidationWarning[] = [] + + // Check required field mappings first + const requiredFields = IMPORT_FIELDS.filter(f => f.required) + for (const field of requiredFields) { + if (!columnMapping[field.key]) { + errors.push({ row: 0, field: field.key, message: `Required field "${field.label}" is not mapped` }) + } + } + + // If required fields are missing, don't validate data + if (errors.length > 0) { + return { isValid: false, errors, warnings } + } + + const skuField = columnMapping.sku + const nameField = columnMapping.name + const quantityField = columnMapping.quantity + + // Track seen SKUs for duplicate detection + const seenSkus = new Set() + + data.forEach((row, idx) => { + const rowNum = idx + 1 + + // Required fields validation + if (skuField) { + const sku = row[skuField] + if (!sku || sku.trim() === '') { + errors.push({ row: rowNum, field: 'sku', message: 'SKU is required' }) + } else if (seenSkus.has(sku)) { + warnings.push({ row: rowNum, field: 'sku', message: `Duplicate SKU: ${sku}` }) + } else { + seenSkus.add(sku) + } + } + + if (nameField) { + const name = row[nameField] + if (!name || name.trim() === '') { + errors.push({ row: rowNum, field: 'name', message: 'Name is required' }) + } + } + + if (quantityField) { + const quantity = row[quantityField] + const numQuantity = parseFloat(quantity) + + if (!quantity || quantity.trim() === '') { + // Quantity is optional in IMPORT_FIELDS but let's warn if missing + } else if (isNaN(numQuantity)) { + errors.push({ row: rowNum, field: 'quantity', message: 'Invalid quantity: must be a number' }) + } else if (numQuantity < 0) { + warnings.push({ row: rowNum, field: 'quantity', message: `Negative quantity: ${numQuantity}` }) + } + } + }) + + return { + isValid: errors.length === 0, + errors, + warnings, + } + }, [data, columnMapping]) + + const errorCount = validation.errors.length + const warningCount = validation.warnings.length + + return ( +
+
+

+ Validate Your Data +

+

+ Review errors and warnings before importing +

+
+ + {/* Summary cards */} +
+
+
+ {validation.isValid ? ( + + ) : ( + + )} +
+
+ {validation.isValid ? 'Valid' : 'Invalid'} +
+
+ {data.length} rows +
+
+
+
+ +
+
+ +
+
+ {errorCount} +
+
+ Errors +
+
+
+
+ +
+
+ +
+
+ {warningCount} +
+
+ Warnings +
+
+
+
+
+ + {/* Errors section */} + {errorCount > 0 && ( +
+

+ + Errors ({errorCount}) +

+
+ {validation.errors.map((error, idx) => ( +
+ +
+ + Row {error.row} + + - {error.message} +
+
+ ))} +
+
+ )} + + {/* Warnings section */} + {warningCount > 0 && ( +
+

+ + Warnings ({warningCount}) +

+
+ {validation.warnings.map((warning, idx) => ( +
+ +
+ + Row {warning.row} + + - {warning.message} +
+
+ ))} +
+
+ )} + + {/* No issues */} + {errorCount === 0 && warningCount === 0 && !error && ( +
+ +

+ All data looks good! No issues found. +

+
+ )} + + {/* Import error */} + {error && ( +
+
+ )} + +
+ + +
+
+ ) +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index f9425ee..647fc9b 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client' import { RouterProvider, createRouter } from '@tanstack/react-router' import { routeTree } from './routeTree.gen' +import './styles.css' + const router = createRouter({ routeTree, defaultPreload: 'intent', diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 3178b44..8078496 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,118 +1,9 @@ -import { createFileRoute } from '@tanstack/react-router' -import { - Zap, - Server, - Route as RouteIcon, - Shield, - Waves, - Sparkles, -} from 'lucide-react' - -export const Route = createFileRoute('/')({ component: App }) - -function App() { - const features = [ - { - icon: , - title: 'Powerful Server Functions', - description: - 'Write server-side code that seamlessly integrates with your client components. Type-safe, secure, and simple.', - }, - { - icon: , - title: 'Flexible Server Side Rendering', - description: - 'Full-document SSR, streaming, and progressive enhancement out of the box. Control exactly what renders where.', - }, - { - icon: , - title: 'API Routes', - description: - 'Build type-safe API endpoints alongside your application. No separate backend needed.', - }, - { - icon: , - title: 'Strongly Typed Everything', - description: - 'End-to-end type safety from server to client. Catch errors before they reach production.', - }, - { - icon: , - title: 'Full Streaming Support', - description: - 'Stream data from server to client progressively. Perfect for AI applications and real-time updates.', - }, - { - icon: , - title: 'Next Generation Ready', - description: - 'Built from the ground up for modern web applications. Deploy anywhere JavaScript runs.', - }, - ] - - return ( -
-
-
-
-
- TanStack Logo -

- TANSTACK{' '} - - START - -

-
-

- The framework for next generation AI applications -

-

- Full-stack framework powered by TanStack Router for React and Solid. - Build modern applications with server functions, streaming, and type - safety. -

-
- - Documentation - -

- Begin your TanStack Start journey by editing{' '} - - /src/routes/index.tsx - -

-
-
-
- -
-
- {features.map((feature, index) => ( -
-
{feature.icon}
-

- {feature.title} -

-

- {feature.description} -

-
- ))} -
-
-
- ) -} +import { createFileRoute, redirect } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + beforeLoad: () => { + throw redirect({ + to: '/setup', + }) + }, +}) diff --git a/apps/web/src/routes/setup.tsx b/apps/web/src/routes/setup.tsx new file mode 100644 index 0000000..636246a --- /dev/null +++ b/apps/web/src/routes/setup.tsx @@ -0,0 +1,196 @@ +import { useState, useCallback } from 'react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { FileSelection, Preview, ColumnMappingComponent, Validation, Complete } from '../components/setup' +import { type SetupStep, type ColumnMapping, type ParsedData } from '../types/setup' +import { importProducts } from '@wareflow/db' + +export const Route = createFileRoute('/setup')({ + component: SetupPage, +}) + +const STEPS = ['file-selection', 'preview', 'column-mapping', 'validation'] as const +const STEP_LABELS = ['File', 'Preview', 'Mapping', 'Validate'] + +function SetupPage() { + const navigate = useNavigate() + + const [step, setStep] = useState('file-selection') + const [file, setFile] = useState(null) + const [parsedData, setParsedData] = useState([]) + const [headers, setHeaders] = useState([]) + const [rowCount, setRowCount] = useState(0) + const [columnMapping, setColumnMapping] = useState({}) + const [importedCount, setImportedCount] = useState(0) + const [isImporting, setIsImporting] = useState(false) + const [importError, setImportError] = useState(null) + + const handleFileSelect = useCallback((selectedFile: File, data: ParsedData[], fileHeaders: string[]) => { + setFile(selectedFile) + setParsedData(data) + setHeaders(fileHeaders) + setRowCount(data.length) + setStep('preview') + }, []) + + const handleBack = useCallback(() => { + switch (step) { + case 'preview': + setStep('file-selection') + setFile(null) + setParsedData([]) + setHeaders([]) + setRowCount(0) + break + case 'column-mapping': + setStep('preview') + break + case 'validation': + setStep('column-mapping') + break + } + }, [step]) + + const handleNext = useCallback(() => { + switch (step) { + case 'preview': + setStep('column-mapping') + break + case 'column-mapping': + setStep('validation') + break + } + }, [step]) + + const handleImport = useCallback(async () => { + setIsImporting(true) + setImportError(null) + try { + const result = await importProducts(parsedData, columnMapping) + setImportedCount(result.imported) + setStep('complete') + } catch (error) { + const message = error instanceof Error ? error.message : 'Import failed' + setImportError(message) + console.error('Import failed:', error) + } finally { + setIsImporting(false) + } + }, [parsedData, columnMapping]) + + const handleViewProducts = useCallback(() => { + navigate({ to: '/' }) + }, [navigate]) + + const handleImportAnother = useCallback(() => { + setStep('file-selection') + setFile(null) + setParsedData([]) + setHeaders([]) + setRowCount(0) + setColumnMapping({}) + setImportedCount(0) + }, []) + + const currentStepIndex = STEPS.indexOf(step) + + return ( +
+ {/* Progress indicator */} + {step !== 'complete' && ( +
+
+
+ {STEPS.map((s, idx) => { + const isActive = s === step + const isCompleted = idx < currentStepIndex + + return ( +
+
+
+ {isCompleted ? ( + + + + ) : ( + idx + 1 + )} +
+ + {STEP_LABELS[idx]} + +
+ {idx < STEPS.length - 1 && ( +
+ )} +
+ ) + })} +
+
+
+ )} + + {/* Step content */} +
+
+ {step === 'file-selection' && ( + + )} + + {step === 'preview' && file && ( + + )} + + {step === 'column-mapping' && ( + + )} + + {step === 'validation' && ( + + )} + + {step === 'complete' && ( + + )} +
+
+
+ ) +} diff --git a/apps/web/src/types/setup.ts b/apps/web/src/types/setup.ts new file mode 100644 index 0000000..20b1ca1 --- /dev/null +++ b/apps/web/src/types/setup.ts @@ -0,0 +1,56 @@ +export type ImportField = { + key: 'sku' | 'name' | 'quantity' | 'sector' | 'zone' | 'floor' | 'description' | 'category' | 'unit' + label: string + required: boolean + defaultValue?: string +} + +export const IMPORT_FIELDS: ImportField[] = [ + { key: 'sku', label: 'SKU', required: true }, + { key: 'name', label: 'Name', required: true }, + { key: 'quantity', label: 'Quantity', required: true }, + { key: 'sector', label: 'Sector', required: false }, + { key: 'zone', label: 'Zone', required: false }, + { key: 'floor', label: 'Floor', required: false }, + { key: 'description', label: 'Description', required: false }, + { key: 'category', label: 'Category', required: false }, + { key: 'unit', label: 'Unit', required: false, defaultValue: 'pcs' }, +] + +export type ParsedData = Record + +export type ColumnMapping = Partial> + +export type ValidationError = { + row: number + field: string + message: string +} + +export type ValidationWarning = { + row: number + field: string + message: string +} + +export type ValidationResult = { + isValid: boolean + errors: ValidationError[] + warnings: ValidationWarning[] +} + +export type ImportResult = { + imported: number +} + +export type SetupStep = 'file-selection' | 'preview' | 'column-mapping' | 'validation' | 'complete' + +export interface SetupState { + step: SetupStep + file: File | null + parsedData: ParsedData[] + headers: string[] + rowCount: number + columnMapping: ColumnMapping + validation: ValidationResult +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8250877..df4eb56 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -15,6 +15,10 @@ const config = defineConfig({ tanstackRouter({ target: 'react', autoCodeSplitting: true }), viteReact(), ], + server: { + host: '127.0.0.1', + port: 3000, + }, }) export default config diff --git a/package.json b/package.json index c1b03c7..d7106ed 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "type": "module", "scripts": { "dev": "turbo dev", + "dev:web": "turbo dev --filter=web", + "dev:desktop": "turbo dev --filter=desktop", + "dev:all": "turbo run dev --filter=web --filter=desktop", "build": "turbo build", "lint": "turbo lint", "typecheck": "turbo typecheck", diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..5f72c06 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,18 @@ +{ + "name": "@wareflow/db", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "dexie": "^4.0.11" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/packages/db/src/db.ts b/packages/db/src/db.ts new file mode 100644 index 0000000..e6d1305 --- /dev/null +++ b/packages/db/src/db.ts @@ -0,0 +1,67 @@ +import Dexie, { type Table } from 'dexie' + +export type Warehouse = { + id?: number + name: string + floors: number + createdAt: Date + updatedAt: Date +} + +export type Sector = { + id?: number + warehouseId: number + name: string + createdAt: Date + updatedAt: Date +} + +export type Zone = { + id?: number + sectorId: number + name: string + floor: number + positionX: number + positionY: number + width: number + height: number + color?: string + createdAt: Date + updatedAt: Date +} + +export type Product = { + id?: number + sku: string + name: string + quantity: number + sectorId?: number + zoneId?: number + floor: number + positionX?: number + positionY?: number + description?: string + category?: string + unit: string + createdAt: Date + updatedAt: Date +} + +export class WarehouseDB extends Dexie { + warehouses!: Table + sectors!: Table + zones!: Table + products!: Table + + constructor() { + super('wareflow') + this.version(1).stores({ + warehouses: '++id, name', + sectors: '++id, warehouseId, name', + zones: '++id, sectorId, name, floor', + products: '++id, sku, sectorId, zoneId, floor, category' + }) + } +} + +export const db = new WarehouseDB() diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..de83ffa --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,2 @@ +export * from './db' +export * from './utils' diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts new file mode 100644 index 0000000..a052a60 --- /dev/null +++ b/packages/db/src/utils.ts @@ -0,0 +1,85 @@ +import Dexie from 'dexie' +import { db, type Warehouse, type Sector, type Product } from './db' + +export type ParsedData = Record +export type ColumnMapping = Record + +export type ImportResult = { + imported: number + errors?: string[] +} + +export const isSetupRequired = async (): Promise => { + const productCount = await db.products.count() + return productCount === 0 +} + +const parseNumber = (value: string | undefined): number | undefined => { + if (!value) return undefined + const num = parseFloat(value) + return isNaN(num) ? undefined : num +} + +export const importProducts = async ( + data: ParsedData[], + mapping: ColumnMapping +): Promise => { + const errors: string[] = [] + + // Get or create default warehouse + let warehouse = await db.warehouses.toCollection().first() + if (!warehouse) { + const warehouseId = await db.warehouses.add({ + name: 'Main Warehouse', + floors: 6, + createdAt: new Date(), + updatedAt: new Date() + }) + warehouse = { id: warehouseId, name: 'Main Warehouse', floors: 6, createdAt: new Date(), updatedAt: new Date() } + } + + // Get or create sector + const sectorName = data[0]?.[mapping.sector] || 'Default' + let sector = await db.sectors.where('name').equals(sectorName).first() + if (!sector) { + const sectorId = await db.sectors.add({ + warehouseId: warehouse.id!, + name: sectorName, + createdAt: new Date(), + updatedAt: new Date() + }) + sector = { id: sectorId, warehouseId: warehouse.id!, name: sectorName, createdAt: new Date(), updatedAt: new Date() } + } + + const products: Omit[] = data.map(row => ({ + sku: row[mapping.sku] || '', + name: row[mapping.name] || '', + quantity: parseNumber(row[mapping.quantity]) || 0, + sectorId: sector.id, + zoneId: undefined, + floor: parseNumber(row[mapping.floor]) || 0, + positionX: undefined, + positionY: undefined, + description: row[mapping.description] || undefined, + category: row[mapping.category] || undefined, + unit: row[mapping.unit] || 'pcs', + createdAt: new Date(), + updatedAt: new Date() + })) + + try { + // Use bulkPut to handle duplicates (update existing or add new) + await db.products.bulkPut(products) + return { imported: products.length, errors: errors.length > 0 ? errors : undefined } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error during import' + throw new Error(`Import failed: ${message}`) + } +} + +export const resetApp = async (): Promise => { + await db.products.clear() + await db.zones.clear() + await db.sectors.clear() + await db.warehouses.clear() +} diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..5cd4894 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "declaration": true + }, + "include": ["src"] +}