diff --git a/Tiltfile b/Tiltfile index 03a5a9e1..66180f21 100644 --- a/Tiltfile +++ b/Tiltfile @@ -55,6 +55,13 @@ docker_build( live_update=[ sync('./frontend/src', '/app/src'), sync('./frontend/public', '/app/public'), + ], + # Ignore test artifacts and reports + ignore=[ + 'playwright-report/**', + 'test-results/**', + 'node_modules/**', + '.DS_Store' ] ) @@ -550,6 +557,9 @@ watch_settings( '**/*.tmp', '**/*.tmp.*', '**/migrations/versions/*.tmp.*', + # Ignore test artifacts and reports (playwright) + '**/playwright-report/**', + '**/test-results/**', ] ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 475fb1af..d9cb932f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "event-source-polyfill": "^1.0.31", "framer-motion": "^10.18.0", "i18next": "^23.7.0", + "i18next-icu": "^2.4.1", "immer": "^10.0.3", "lucide-react": "^0.294.0", "papaparse": "^5.4.1", @@ -2262,6 +2263,57 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -8499,6 +8551,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -10516,6 +10574,15 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/i18next-icu": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.4.1.tgz", + "integrity": "sha512-fh01aSjGlnsR377J7mu/CM3wcdZTpvNZejapPfHI+YnVyiWwvGFT2gZOgecm9B19ttyAJ3ijmHG6r/2jiQqXCA==", + "license": "MIT", + "peerDependencies": { + "intl-messageformat": "^10.3.3" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -10647,6 +10714,19 @@ "node": ">=12" } }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 139b4b23..68177eaa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,11 @@ "test:e2e:debug": "playwright test --debug", "test:e2e:report": "playwright show-report", "test:e2e:codegen": "playwright codegen http://localhost:5173", + "test:e2e:k8s": "playwright test --config=playwright.k8s.config.ts", + "test:e2e:k8s:ui": "playwright test --config=playwright.k8s.config.ts --ui", + "test:e2e:k8s:headed": "playwright test --config=playwright.k8s.config.ts --headed", + "test:e2e:k8s:debug": "playwright test --config=playwright.k8s.config.ts --debug", + "test:e2e:k8s:codegen": "playwright codegen http://localhost", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,md}\"", "storybook": "storybook dev -p 6006", @@ -47,6 +52,7 @@ "event-source-polyfill": "^1.0.31", "framer-motion": "^10.18.0", "i18next": "^23.7.0", + "i18next-icu": "^2.4.1", "immer": "^10.0.3", "lucide-react": "^0.294.0", "papaparse": "^5.4.1", diff --git a/frontend/playwright.k8s.config.ts b/frontend/playwright.k8s.config.ts new file mode 100644 index 00000000..ddb2e765 --- /dev/null +++ b/frontend/playwright.k8s.config.ts @@ -0,0 +1,119 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright configuration for Bakery-IA E2E tests against local Kubernetes environment + * This config is specifically for testing against Tilt-managed services + * + * Usage: + * npm run test:e2e:k8s + * npx playwright test --config=playwright.k8s.config.ts + * + * Prerequisites: + * - Tilt must be running (`tilt up`) + * - Frontend service must be accessible at http://localhost (via ingress) + */ +export default defineConfig({ + testDir: './tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code */ + forbidOnly: !!process.env.CI, + + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + + /* Opt out of parallel tests on CI */ + workers: process.env.CI ? 1 : undefined, + + /* Reporter to use */ + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['json', { outputFile: 'test-results/results.json' }], + ['list'], + ], + + /* Shared settings for all the projects below */ + use: { + /* Base URL points to K8s ingress - override with PLAYWRIGHT_BASE_URL env var if needed */ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost', + + /* Collect trace when retrying the failed test */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + + /* Record video on failure */ + video: 'retain-on-failure', + + /* Maximum time each action (click, fill, etc) can take */ + actionTimeout: 10000, + + /* Increase navigation timeout for K8s environment (ingress routing may be slower) */ + navigationTimeout: 30000, + }, + + /* Configure projects for major browsers */ + projects: [ + // Setup project to authenticate once + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + + // Desktop browsers + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'webkit', + use: { + ...devices['Desktop Safari'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + // Mobile viewports + { + name: 'Mobile Chrome', + use: { + ...devices['Pixel 5'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + + { + name: 'Mobile Safari', + use: { + ...devices['iPhone 13'], + storageState: 'tests/.auth/user.json', + }, + dependencies: ['setup'], + }, + ], + + /* + * DO NOT start local dev server - Tilt manages the services + * The frontend is served through K8s ingress + */ + // webServer: undefined, +}); diff --git a/frontend/src/components/dashboard/PurchaseOrderDetailsModal.tsx b/frontend/src/components/dashboard/PurchaseOrderDetailsModal.tsx new file mode 100644 index 00000000..697cb9d3 --- /dev/null +++ b/frontend/src/components/dashboard/PurchaseOrderDetailsModal.tsx @@ -0,0 +1,409 @@ +// ================================================================ +// frontend/src/components/dashboard/PurchaseOrderDetailsModal.tsx +// ================================================================ +/** + * Purchase Order Details Modal + * Quick view of PO details from the Action Queue + */ + +import React from 'react'; +import { + X, + Package, + Building2, + Calendar, + Truck, + Euro, + FileText, + CheckCircle, + Clock, + AlertCircle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { usePurchaseOrder } from '../../api/hooks/purchase-orders'; +import { formatDistanceToNow } from 'date-fns'; +import { es, enUS, eu as euLocale } from 'date-fns/locale'; + +interface PurchaseOrderDetailsModalProps { + poId: string; + tenantId: string; + isOpen: boolean; + onClose: () => void; + onApprove?: (poId: string) => void; + onModify?: (poId: string) => void; +} + +const localeMap = { + es: es, + en: enUS, + eu: euLocale, +}; + +export const PurchaseOrderDetailsModal: React.FC = ({ + poId, + tenantId, + isOpen, + onClose, + onApprove, + onModify, +}) => { + const { t, i18n } = useTranslation(); + const { data: po, isLoading } = usePurchaseOrder(tenantId, poId); + + if (!isOpen) return null; + + const dateLocale = localeMap[i18n.language as keyof typeof localeMap] || enUS; + + // Format currency + const formatCurrency = (value: any) => { + const num = Number(value); + return isNaN(num) ? '0.00' : num.toFixed(2); + }; + + const getStatusBadge = (status: string) => { + const statusConfig = { + draft: { + bg: 'var(--color-gray-100)', + text: 'var(--color-gray-700)', + label: t('purchase_orders:status.draft'), + }, + pending_approval: { + bg: 'var(--color-warning-100)', + text: 'var(--color-warning-700)', + label: t('purchase_orders:status.pending_approval'), + }, + approved: { + bg: 'var(--color-success-100)', + text: 'var(--color-success-700)', + label: t('purchase_orders:status.approved'), + }, + sent: { + bg: 'var(--color-info-100)', + text: 'var(--color-info-700)', + label: t('purchase_orders:status.sent'), + }, + partially_received: { + bg: 'var(--color-warning-100)', + text: 'var(--color-warning-700)', + label: t('purchase_orders:status.partially_received'), + }, + received: { + bg: 'var(--color-success-100)', + text: 'var(--color-success-700)', + label: t('purchase_orders:status.received'), + }, + cancelled: { + bg: 'var(--color-error-100)', + text: 'var(--color-error-700)', + label: t('purchase_orders:status.cancelled'), + }, + }; + + const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.draft; + + return ( + + {config.label} + + ); + }; + + return ( +
+
e.stopPropagation()} + style={{ + backgroundColor: 'var(--bg-primary)', + animation: 'slideUp 0.3s ease-out' + }} + > + + {/* Header */} +
+
+
+ +
+
+

+ {isLoading ? t('common:loading') : po?.po_number || t('purchase_orders:purchase_order')} +

+ {po && ( +

+ + {t('purchase_orders:created')} {formatDistanceToNow(new Date(po.created_at), { addSuffix: true, locale: dateLocale })} +

+ )} +
+
+ +
+ + {/* Content */} +
+ {isLoading ? ( +
+
+
+ ) : po ? ( +
+ {/* Status and Key Info */} +
+ {getStatusBadge(po.status)} +
+

+ {t('purchase_orders:total_amount')} +

+
+ + + {formatCurrency(po.total_amount)} + +
+
+
+ + {/* Supplier Info */} +
+
+
+ +
+

+ {t('purchase_orders:supplier')} +

+
+

+ {po.supplier_name || t('common:unknown')} +

+
+ + {/* Dates */} +
+
+
+
+ +
+

+ {t('purchase_orders:order_date')} +

+
+

+ {new Date(po.order_date).toLocaleDateString(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +

+
+ + {po.expected_delivery_date && ( +
+
+
+ +
+

+ {t('purchase_orders:expected_delivery')} +

+
+

+ {new Date(po.expected_delivery_date).toLocaleDateString(i18n.language, { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +

+
+ )} +
+ + {/* Items */} +
+
+
+ +
+

+ {t('purchase_orders:items')} +

+ {po.items && po.items.length > 0 && ( + + {po.items.length} {po.items.length === 1 ? t('common:item') : t('common:items')} + + )} +
+
+ {po.items && po.items.length > 0 ? ( + po.items.map((item: any, index: number) => ( +
+
+

+ {item.ingredient_name || item.product_name} +

+

+ {item.quantity} {item.unit} + × + €{formatCurrency(item.unit_price)} +

+
+

+ €{formatCurrency(item.subtotal)} +

+
+ )) + ) : ( +
+ +

{t('purchase_orders:no_items')}

+
+ )} +
+
+ + {/* Notes */} + {po.notes && ( +
+
+
+ +
+

+ {t('purchase_orders:notes')} +

+
+

{po.notes}

+
+ )} +
+ ) : ( +
+

{t('purchase_orders:not_found')}

+
+ )} +
+ + {/* Footer Actions */} + {po && po.status === 'pending_approval' && ( +
+ + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/domain/unified-wizard/wizards/InventoryWizard.tsx b/frontend/src/components/domain/unified-wizard/wizards/InventoryWizard.tsx index 3aea15cc..6984c85d 100644 --- a/frontend/src/components/domain/unified-wizard/wizards/InventoryWizard.tsx +++ b/frontend/src/components/domain/unified-wizard/wizards/InventoryWizard.tsx @@ -3,820 +3,477 @@ import { useTranslation } from 'react-i18next'; import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal'; import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection'; import Tooltip from '../../../ui/Tooltip/Tooltip'; -import { Info } from 'lucide-react'; +import { Info, Package, ShoppingBag } from 'lucide-react'; interface WizardDataProps extends WizardStepProps { data: Record; onDataChange: (data: Record) => void; } -// Single comprehensive step with all fields -const InventoryDetailsStep: React.FC = ({ data, onDataChange }) => { +// STEP 1: Product Type Selection with advanced fields +const ProductTypeStep: React.FC = ({ data, onDataChange }) => { const { t } = useTranslation('wizards'); - const [inventoryData, setInventoryData] = useState({ - // Required fields - name: data.name || '', - unitOfMeasure: data.unitOfMeasure || '', - productType: data.productType || 'ingredient', + const handleFieldChange = (field: string, value: any) => { + onDataChange({ ...data, [field]: value }); + }; - // Basic fields - sku: data.sku || '', - barcode: data.barcode || '', - ingredientCategory: data.ingredientCategory || '', - productCategory: data.productCategory || '', - description: data.description || '', - brand: data.brand || '', - - // Pricing fields - averageCost: data.averageCost || '', - lastPurchasePrice: data.lastPurchasePrice || '', - standardCost: data.standardCost || '', - sellingPrice: data.sellingPrice || '', - minimumPrice: data.minimumPrice || '', - - // Inventory management - lowStockThreshold: data.lowStockThreshold || '', - reorderPoint: data.reorderPoint || '', - reorderQuantity: data.reorderQuantity || '', - maxStockLevel: data.maxStockLevel || '', - leadTimeDays: data.leadTimeDays || '', - - // Product information - packageSize: data.packageSize || '', - shelfLifeDays: data.shelfLifeDays || '', - displayLifeHours: data.displayLifeHours || '', - storageTempMin: data.storageTempMin || '', - storageTempMax: data.storageTempMax || '', - - // Storage and handling - storageInstructions: data.storageInstructions || '', - isPerishable: data.isPerishable ?? true, - handlingInstructions: data.handlingInstructions || '', - - // Supplier information - preferredSupplierId: data.preferredSupplierId || '', - supplierProductCode: data.supplierProductCode || '', - - // Quality and compliance - allergenInfo: data.allergenInfo || '', - nutritionalInfo: data.nutritionalInfo || '', - certifications: data.certifications || '', - - // Physical properties - weight: data.weight || '', - volume: data.volume || '', - dimensions: data.dimensions || '', - color: data.color || '', - - // Status and tracking - isActive: data.isActive ?? true, - trackByLot: data.trackByLot ?? false, - trackByExpiry: data.trackByExpiry ?? true, - allowNegativeStock: data.allowNegativeStock ?? false, - - // Metadata - notes: data.notes || '', - tags: data.tags || '', - customFields: data.customFields || '', - }); - - // Update parent whenever local state changes - const handleDataChange = (newInventoryData: any) => { - setInventoryData(newInventoryData); - onDataChange({ ...data, ...newInventoryData }); + const handleTypeSelect = (type: string) => { + onDataChange({ ...data, productType: type }); }; return (
-
-

- {t('inventory.inventoryDetails')} -

-

- {t('inventory.fillRequiredInfo')} -

-
- - {/* Required Fields */} -
-
- - handleDataChange({ ...inventoryData, name: e.target.value })} - placeholder={t('inventory.fields.namePlaceholder')} - className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" - /> -
- -
- - -
- -
- - -
-
- - {/* Basic Information */} -
-

{t('inventory.sections.basicInformation')}

+ {/* Product Type Selection */} +
+
+ {/* Ingredient Card */} + + + {/* Finished Product Card */} + +
+
+ + {/* Advanced Fields Section */} + +
+ {/* SKU */}
handleDataChange({ ...inventoryData, sku: e.target.value })} + value={data.sku || ''} + onChange={(e) => handleFieldChange('sku', e.target.value)} placeholder={t('inventory.fields.skuPlaceholder')} className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" />
+ {/* Barcode */}
handleDataChange({ ...inventoryData, barcode: e.target.value })} + value={data.barcode || ''} + onChange={(e) => handleFieldChange('barcode', e.target.value)} placeholder={t('inventory.fields.barcodePlaceholder')} className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" />
-
- - -
- -
+ {/* Brand */} +
handleDataChange({ ...inventoryData, brand: e.target.value })} + value={data.brand || ''} + onChange={(e) => handleFieldChange('brand', e.target.value)} placeholder={t('inventory.fields.brandPlaceholder')} className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" />
+
+ +
+ ); +}; -
+// STEP 2: Basic Information with advanced fields +const BasicInfoStep: React.FC = ({ data, onDataChange }) => { + const { t } = useTranslation('wizards'); + + const handleFieldChange = (field: string, value: any) => { + onDataChange({ ...data, [field]: value }); + }; + + return ( +
+ {/* Required Fields */} +
+ + handleFieldChange('name', e.target.value)} + placeholder={t('inventory.fields.namePlaceholder')} + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + +
+ + {/* Advanced Fields Section */} + +
+ {/* Description */} +