diff --git a/frontend/src/api/hooks/inventory.ts b/frontend/src/api/hooks/inventory.ts index b1f843a8..1ec1da58 100644 --- a/frontend/src/api/hooks/inventory.ts +++ b/frontend/src/api/hooks/inventory.ts @@ -8,6 +8,7 @@ import { IngredientCreate, IngredientUpdate, IngredientResponse, + BulkIngredientResponse, StockCreate, StockUpdate, StockResponse, @@ -239,7 +240,7 @@ export const useCreateIngredient = ( options?: UseMutationOptions ) => { const queryClient = useQueryClient(); - + return useMutation({ mutationFn: ({ tenantId, ingredientData }) => inventoryService.createIngredient(tenantId, ingredientData), onSuccess: (data, { tenantId }) => { @@ -253,6 +254,22 @@ export const useCreateIngredient = ( }); }; +export const useBulkCreateIngredients = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ tenantId, ingredients }) => inventoryService.bulkCreateIngredients(tenantId, ingredients), + onSuccess: (data, { tenantId }) => { + // Invalidate all ingredient lists to refetch + queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); + queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.byCategory(tenantId) }); + }, + ...options, + }); +}; + export const useUpdateIngredient = ( options?: UseMutationOptions< IngredientResponse, diff --git a/frontend/src/api/services/inventory.ts b/frontend/src/api/services/inventory.ts index f4e6a4bf..668acb89 100644 --- a/frontend/src/api/services/inventory.ts +++ b/frontend/src/api/services/inventory.ts @@ -21,6 +21,7 @@ import { IngredientUpdate, IngredientResponse, IngredientFilter, + BulkIngredientResponse, // Stock StockCreate, StockUpdate, @@ -72,6 +73,16 @@ export class InventoryService { ); } + async bulkCreateIngredients( + tenantId: string, + ingredients: IngredientCreate[] + ): Promise { + return apiClient.post( + `${this.baseUrl}/${tenantId}/inventory/ingredients/bulk`, + { ingredients } + ); + } + async getIngredient(tenantId: string, ingredientId: string): Promise { return apiClient.get( `${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}` diff --git a/frontend/src/api/types/inventory.ts b/frontend/src/api/types/inventory.ts index f726568d..d5565bcb 100644 --- a/frontend/src/api/types/inventory.ts +++ b/frontend/src/api/types/inventory.ts @@ -177,6 +177,28 @@ export interface IngredientResponse { needs_reorder?: boolean | null; } +// ===== BULK INGREDIENT SCHEMAS ===== +// Mirror: BulkIngredientCreate, BulkIngredientResult, BulkIngredientResponse from inventory.py + +export interface BulkIngredientCreate { + ingredients: IngredientCreate[]; +} + +export interface BulkIngredientResult { + index: number; + success: boolean; + ingredient: IngredientResponse | null; + error: string | null; +} + +export interface BulkIngredientResponse { + total_requested: number; + total_created: number; + total_failed: number; + results: BulkIngredientResult[]; + transaction_id: string; +} + // ===== STOCK SCHEMAS ===== // Mirror: StockCreate from inventory.py:140 diff --git a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx index c2f13d5d..262fed56 100644 --- a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx @@ -49,7 +49,7 @@ interface StepProps { } const OnboardingWizardContent: React.FC = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { user } = useAuth(); @@ -77,7 +77,8 @@ const OnboardingWizardContent: React.FC = () => { // All possible steps with conditional visibility // All step IDs match backend ONBOARDING_STEPS exactly - const ALL_STEPS: StepConfig[] = [ + // Wrapped in useMemo to re-evaluate when language changes + const ALL_STEPS: StepConfig[] = useMemo(() => [ // Phase 1: Discovery { id: 'bakery-type-selection', @@ -119,8 +120,8 @@ const OnboardingWizardContent: React.FC = () => { // Enterprise-specific: Child Tenants Setup { id: 'child-tenants-setup', - title: 'Configurar Sucursales', - description: 'Registra las sucursales de tu red empresarial', + title: t('onboarding:wizard.steps.child_tenants.title', 'Configurar Sucursales'), + description: t('onboarding:wizard.steps.child_tenants.description', 'Registra las sucursales de tu red empresarial'), component: ChildTenantsSetupStep, isConditional: true, condition: () => { @@ -203,7 +204,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.completion.description', '¡Todo listo!'), component: CompletionStep, }, - ]; + ], [i18n.language]); // Re-create steps when language changes // Filter visible steps based on wizard context // useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes @@ -225,7 +226,7 @@ const OnboardingWizardContent: React.FC = () => { }); return visibleSteps; - }, [wizardContext.state, isEnterprise]); // Added isEnterprise to dependencies + }, [ALL_STEPS, wizardContext.state, isEnterprise]); // Added ALL_STEPS to re-filter when translations change const isNewTenant = searchParams.get('new') === 'true'; const [currentStepIndex, setCurrentStepIndex] = useState(0); diff --git a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx index c9eb1af7..03556a38 100644 --- a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx @@ -58,7 +58,7 @@ export const CompletionStep: React.FC = ({ {/* Success Message */}
-

+

{t('onboarding:completion.congratulations', '¡Felicidades! Tu Sistema Está Listo')}

diff --git a/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx b/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx index b57fe1d3..7d8ab380 100644 --- a/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/InitialStockEntryStep.tsx @@ -57,27 +57,30 @@ export const InitialStockEntryStep: React.FC = ({ // Merge existing stock from backend on mount useEffect(() => { - if (stockData?.items && products.length > 0) { - console.log('🔄 Merging backend stock data into initial stock entry state...', { itemsCount: stockData.items.length }); + if (!stockData?.items || products.length === 0) return; - let hasChanges = false; - const updatedProducts = products.map(p => { - const existingStock = stockData?.items?.find(s => s.ingredient_id === p.id); - if (existingStock && p.initialStock !== existingStock.current_quantity) { - hasChanges = true; - return { - ...p, - initialStock: existingStock.current_quantity - }; - } - return p; - }); + console.log('🔄 Merging backend stock data into initial stock entry state...', { itemsCount: stockData.items.length }); - if (hasChanges) { - setProducts(updatedProducts); + let hasChanges = false; + const updatedProducts = products.map(p => { + const existingStock = stockData.items.find(s => s.ingredient_id === p.id); + + // Only merge if user hasn't entered value yet + if (existingStock && p.initialStock === undefined) { + hasChanges = true; + return { + ...p, + initialStock: existingStock.current_quantity + }; } + return p; + }); + + if (hasChanges) { + setProducts(updatedProducts); + onUpdate?.({ productsWithStock: updatedProducts }); } - }, [stockData, products]); // Run when stock data changes or products list is initialized + }, [stockData]); // Only depend on stockData to avoid infinite loop const ingredients = products.filter(p => p.type === 'ingredient'); const finishedProducts = products.filter(p => p.type === 'finished_product'); diff --git a/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx b/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx index 00664ee3..751b5676 100644 --- a/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/InventoryReviewStep.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../../ui/Button'; import { useCurrentTenant } from '../../../../stores/tenant.store'; -import { useCreateIngredient, useIngredients } from '../../../../api/hooks/inventory'; +import { useCreateIngredient, useBulkCreateIngredients, useIngredients } from '../../../../api/hooks/inventory'; import { useImportSalesData } from '../../../../api/hooks/sales'; import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory'; import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory'; @@ -140,6 +140,7 @@ export const InventoryReviewStep: React.FC = ({ // API hooks const createIngredientMutation = useCreateIngredient(); + const bulkCreateIngredientsMutation = useBulkCreateIngredients(); const importSalesMutation = useImportSalesData(); const { data: existingIngredients } = useIngredients(tenantId); @@ -333,26 +334,61 @@ export const InventoryReviewStep: React.FC = ({ console.log(`📦 Inventory processing: ${itemsToCreate.length} to create, ${existingMatches.length} already exist.`); - // STEP 1: Create new inventory items in parallel - const createPromises = itemsToCreate.map((item, index) => { - const ingredientData: IngredientCreate = { - name: item.name, - product_type: item.product_type, - category: item.category, - unit_of_measure: item.unit_of_measure as UnitOfMeasure, - }; + // STEP 1: Create new inventory items using bulk API (with fallback to individual creates) + let newlyCreatedIngredients: any[] = []; - return createIngredientMutation.mutateAsync({ - tenantId, - ingredientData, - }).catch(error => { - console.error(`❌ Failed to create ingredient "${item.name}":`, error); - throw error; - }); - }); + if (itemsToCreate.length > 0) { + try { + // Try bulk creation first (more efficient) + const ingredientsData: IngredientCreate[] = itemsToCreate.map(item => ({ + name: item.name, + product_type: item.product_type, + category: item.category, + unit_of_measure: item.unit_of_measure as UnitOfMeasure, + })); - const newlyCreatedIngredients = await Promise.all(createPromises); - console.log('✅ New inventory items created successfully'); + const bulkResult = await bulkCreateIngredientsMutation.mutateAsync({ + tenantId, + ingredients: ingredientsData, + }); + + // Extract successfully created ingredients + newlyCreatedIngredients = bulkResult.results + .filter(r => r.success && r.ingredient) + .map(r => r.ingredient!); + + console.log(`✅ Bulk creation: ${bulkResult.total_created}/${bulkResult.total_requested} items created successfully`); + + // Log any failures + if (bulkResult.total_failed > 0) { + const failures = bulkResult.results.filter(r => !r.success); + console.warn(`⚠️ ${bulkResult.total_failed} items failed:`, failures.map(f => f.error)); + } + } catch (bulkError) { + console.warn('⚠️ Bulk create failed, falling back to individual creates:', bulkError); + + // Fallback: Create items individually in parallel + const createPromises = itemsToCreate.map((item, index) => { + const ingredientData: IngredientCreate = { + name: item.name, + product_type: item.product_type, + category: item.category, + unit_of_measure: item.unit_of_measure as UnitOfMeasure, + }; + + return createIngredientMutation.mutateAsync({ + tenantId, + ingredientData, + }).catch(error => { + console.error(`❌ Failed to create ingredient "${item.name}":`, error); + throw error; + }); + }); + + newlyCreatedIngredients = await Promise.all(createPromises); + console.log('✅ Fallback: New inventory items created successfully via individual requests'); + } + } // STEP 2: Import sales data (only if file was uploaded) let salesImported = false; @@ -467,10 +503,10 @@ export const InventoryReviewStep: React.FC = ({ {template.icon}

- {template.name} + {t(`setup_wizard:inventory.templates.${template.id}`, template.name)}

- {template.description} + {t(`setup_wizard:inventory.templates.${template.id}-desc`, template.description)}

{template.items.length} {t('inventory:templates.items', 'ingredientes')} diff --git a/frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx b/frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx index df94cd61..159df535 100644 --- a/frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx +++ b/frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx @@ -592,7 +592,7 @@ export const RecipesSetupStep: React.FC = ({ onUpdate, onComplet value={formData.yield_quantity} onChange={(e) => setFormData({ ...formData, yield_quantity: e.target.value })} className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.yield_quantity ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`} - placeholder="10" + placeholder={t('setup_wizard:recipes.placeholders.yield_quantity', '10')} /> {errors.yield_quantity &&

{errors.yield_quantity}

}
@@ -666,7 +666,7 @@ export const RecipesSetupStep: React.FC = ({ onUpdate, onComplet step="0.01" value={ing.quantity} onChange={(e) => updateIngredient(index, 'quantity', e.target.value)} - placeholder="Qty" + placeholder={t('setup_wizard:recipes.placeholders.ingredient_quantity', 'Qty')} className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_quantity`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`} /> {errors[`ingredient_${index}_quantity`] &&

{errors[`ingredient_${index}_quantity`]}

} diff --git a/frontend/src/locales/en/onboarding.json b/frontend/src/locales/en/onboarding.json index 2c139ba0..b5897349 100644 --- a/frontend/src/locales/en/onboarding.json +++ b/frontend/src/locales/en/onboarding.json @@ -3,12 +3,20 @@ "title": "Initial Setup", "subtitle": "We'll guide you step by step to configure your bakery", "steps": { + "bakery_type": { + "title": "Bakery Type", + "description": "Select your business type" + }, "setup": { "title": "Register Bakery", "title_enterprise": "Register Central Bakery", "description": "Configure your bakery's basic information", "description_enterprise": "Central bakery information" }, + "child_tenants": { + "title": "Configure Branches", + "description": "Register the branches of your enterprise network" + }, "poi_detection": { "title": "Location Analysis", "description": "Detect nearby points of interest" @@ -17,6 +25,34 @@ "title": "Configure Inventory", "description": "Upload sales data and set up your initial inventory" }, + "upload_sales": { + "title": "Upload Sales Data", + "description": "Load file with sales history" + }, + "inventory_review": { + "title": "Review Inventory", + "description": "Confirm detected products" + }, + "stock": { + "title": "Stock Levels", + "description": "Initial quantities" + }, + "suppliers": { + "title": "Suppliers", + "description": "Configure your suppliers" + }, + "recipes": { + "title": "Recipes", + "description": "Production recipes" + }, + "quality": { + "title": "Quality", + "description": "Quality standards" + }, + "team": { + "title": "Team", + "description": "Team members" + }, "ml_training": { "title": "AI Training", "description": "Train your personalized artificial intelligence model" @@ -180,6 +216,54 @@ } } }, + "bakery_type": { + "title": "What type of bakery do you have?", + "subtitle": "This will help us personalize the experience and show you only the features you need", + "features_label": "Features", + "examples_label": "Examples", + "continue_button": "Continue", + "help_text": "💡 Don't worry, you can always change this later in settings", + "selected_info_title": "Perfect for your bakery", + "production": { + "name": "Production Bakery", + "description": "We produce from scratch using basic ingredients", + "feature1": "Complete recipe management", + "feature2": "Ingredient and cost control", + "feature3": "Production planning", + "feature4": "Raw material quality control", + "example1": "Artisan bread", + "example2": "Pastries", + "example3": "Confectionery", + "example4": "Patisserie", + "selected_info": "We'll set up a complete recipe, ingredient, and production management system tailored to your workflow." + }, + "retail": { + "name": "Retail Bakery", + "description": "We bake and sell pre-made products", + "feature1": "Finished product control", + "feature2": "Simple baking management", + "feature3": "Point of sale inventory control", + "feature4": "Sales and shrinkage tracking", + "example1": "Pre-baked bread", + "example2": "Frozen products to finish", + "example3": "Ready-to-sell pastries", + "example4": "Cakes and pastries from suppliers", + "selected_info": "We'll set up a simple system focused on inventory control, baking, and sales without the complexity of recipes." + }, + "mixed": { + "name": "Mixed Bakery", + "description": "We combine own production with finished products", + "feature1": "Own recipes and external products", + "feature2": "Total management flexibility", + "feature3": "Complete cost control", + "feature4": "Maximum adaptability", + "example1": "Own bread + supplier pastries", + "example2": "Own cakes + pre-baked goods", + "example3": "Artisan + industrial products", + "example4": "Seasonal combination", + "selected_info": "We'll set up a flexible system that allows you to manage both own production and external products according to your needs." + } + }, "errors": { "step_failed": "Error in this step", "data_invalid": "Invalid data", @@ -210,6 +294,7 @@ "incomplete_warning": "{count} products remaining", "incomplete_help": "You can continue, but we recommend entering all quantities for better inventory control.", "complete": "Complete Setup", + "continue_to_next": "Continue", "continue_anyway": "Continue anyway", "no_products_title": "Initial Stock", "no_products_message": "You can configure stock levels later in the inventory section." diff --git a/frontend/src/locales/en/setup_wizard.json b/frontend/src/locales/en/setup_wizard.json index d82fd8b3..ccaca349 100644 --- a/frontend/src/locales/en/setup_wizard.json +++ b/frontend/src/locales/en/setup_wizard.json @@ -114,6 +114,16 @@ "quantity_required": "Quantity must be greater than zero", "expiration_past": "Expiration date is in the past", "expiring_soon": "Warning: This ingredient expires very soon!" + }, + "templates": { + "basic-bakery": "Basic Bakery Ingredients", + "basic-bakery-desc": "Essential ingredients for any bakery", + "pastry-essentials": "Pastry Essentials", + "pastry-essentials-desc": "Ingredients for cakes and desserts", + "bread-basics": "Bread Basics", + "bread-basics-desc": "Everything needed for artisan bread", + "chocolate-specialties": "Chocolate Specialties", + "chocolate-specialties-desc": "For chocolate products" } }, "recipes": { @@ -153,7 +163,9 @@ }, "placeholders": { "name": "e.g., Baguette, Croissant", - "finished_product": "Select finished product..." + "finished_product": "Select finished product...", + "yield_quantity": "10", + "ingredient_quantity": "Qty" }, "errors": { "name_required": "Recipe name is required", diff --git a/frontend/src/locales/es/onboarding.json b/frontend/src/locales/es/onboarding.json index c6206587..401f30be 100644 --- a/frontend/src/locales/es/onboarding.json +++ b/frontend/src/locales/es/onboarding.json @@ -18,6 +18,10 @@ "description": "Información básica", "description_enterprise": "Información del obrador central" }, + "child_tenants": { + "title": "Configurar Sucursales", + "description": "Registra las sucursales de tu red empresarial" + }, "poi_detection": { "title": "Análisis de Ubicación", "description": "Detectar puntos de interés cercanos" @@ -30,6 +34,18 @@ "title": "Configurar Inventario", "description": "Sube datos de ventas y configura tu inventario inicial" }, + "upload_sales": { + "title": "Subir Datos de Ventas", + "description": "Cargar archivo con historial de ventas" + }, + "inventory_review": { + "title": "Revisar Inventario", + "description": "Confirmar productos detectados" + }, + "stock": { + "title": "Niveles de Stock", + "description": "Cantidades iniciales" + }, "suppliers": { "title": "Proveedores", "description": "Configura tus proveedores" @@ -400,6 +416,7 @@ "incomplete_warning": "Faltan {count} productos por completar", "incomplete_help": "Puedes continuar, pero recomendamos ingresar todas las cantidades para un mejor control de inventario.", "complete": "Completar Configuración", + "continue_to_next": "Continuar", "continue_anyway": "Continuar de todos modos", "no_products_title": "Stock Inicial", "no_products_message": "Podrás configurar los niveles de stock más tarde en la sección de inventario." diff --git a/frontend/src/locales/es/setup_wizard.json b/frontend/src/locales/es/setup_wizard.json index dad879e1..f673d0e2 100644 --- a/frontend/src/locales/es/setup_wizard.json +++ b/frontend/src/locales/es/setup_wizard.json @@ -153,7 +153,9 @@ }, "placeholders": { "name": "ej., Baguette, Croissant", - "finished_product": "Seleccionar producto terminado..." + "finished_product": "Seleccionar producto terminado...", + "yield_quantity": "10", + "ingredient_quantity": "Cant." }, "errors": { "name_required": "El nombre de la receta es obligatorio", diff --git a/frontend/src/locales/eu/inventory.json b/frontend/src/locales/eu/inventory.json index 2547c83d..a03414a8 100644 --- a/frontend/src/locales/eu/inventory.json +++ b/frontend/src/locales/eu/inventory.json @@ -69,6 +69,40 @@ "cleaning": "Garbiketa", "other": "Besteak" }, + "product_type": { + "ingredient": "Osagaia", + "finished_product": "Produktu Amaitua" + }, + "production_stage": { + "raw_ingredient": "Osagai Gordina", + "par_baked": "Erdi-egosita", + "fully_baked": "Guztiz Egosita", + "prepared_dough": "Orea Prestatuta", + "frozen_product": "Produktu Izoztua" + }, + "unit_of_measure": { + "kg": "Kilogramoak", + "g": "Gramoak", + "l": "Litroak", + "ml": "Mililitroak", + "units": "Unitateak", + "pcs": "Piezak", + "pkg": "Paketeak", + "bags": "Poltsak", + "boxes": "Kutxak" + }, + "product_category": { + "bread": "Ogiak", + "croissants": "Croissantak", + "pastries": "Gozogintza", + "cakes": "Tartak", + "cookies": "Galletak", + "muffins": "Muffinak", + "sandwiches": "Ogitartekoak", + "seasonal": "Sasoikoak", + "beverages": "Edariak", + "other_products": "Beste Produktuak" + }, "stock_movement_type": { "PURCHASE": "Erosketa", "PRODUCTION_USE": "Ekoizpenean Erabilera", diff --git a/frontend/src/locales/eu/onboarding.json b/frontend/src/locales/eu/onboarding.json index c04a1629..2bd696fd 100644 --- a/frontend/src/locales/eu/onboarding.json +++ b/frontend/src/locales/eu/onboarding.json @@ -17,6 +17,10 @@ "description": "Oinarrizko informazioa", "description_enterprise": "Okindegi zentralaren informazioa" }, + "child_tenants": { + "title": "Sukurtsalak Konfiguratu", + "description": "Erregistratu zure enpresa-sareko sukurtsalak" + }, "poi_detection": { "title": "Kokapen Analisia", "description": "Inguruko interesguneak detektatu" @@ -29,6 +33,18 @@ "title": "Inbentarioa Konfiguratu", "description": "Salmenten datuak igo eta hasierako inbentarioa ezarri" }, + "upload_sales": { + "title": "Salmenta Datuak Igo", + "description": "Igo fitxategia salmenta historiarekin" + }, + "inventory_review": { + "title": "Inbentarioa Berrikusi", + "description": "Berretsi detektatutako produktuak" + }, + "stock": { + "title": "Stock Mailak", + "description": "Hasierako kantit aterak" + }, "suppliers": { "title": "Hornitzaileak", "description": "Konfiguratu zure hornitzaileak" @@ -382,6 +398,7 @@ "incomplete_warning": "{count} produktu osatu gabe geratzen dira", "incomplete_help": "Jarraitu dezakezu, baina kopuru guztiak sartzea gomendatzen dizugu inbentario-kontrol hobeagorako.", "complete": "Konfigurazioa Osatu", + "continue_to_next": "Jarraitu", "continue_anyway": "Jarraitu hala ere", "no_products_title": "Hasierako Stocka", "no_products_message": "Stock-mailak geroago konfigura ditzakezu inbentario atalean." diff --git a/frontend/src/locales/eu/setup_wizard.json b/frontend/src/locales/eu/setup_wizard.json index c0d391d6..08616cea 100644 --- a/frontend/src/locales/eu/setup_wizard.json +++ b/frontend/src/locales/eu/setup_wizard.json @@ -114,6 +114,16 @@ "quantity_required": "Kantitatea zero baino handiagoa izan behar da", "expiration_past": "Iraungitze data iraganean dago", "expiring_soon": "Abisua: Osagai hau laster iraungitzen da!" + }, + "templates": { + "basic-bakery": "Oinarrizko Okindegi Osagaiak", + "basic-bakery-desc": "Okindegi orokorrentzako funtsezko osagaiak", + "pastry-essentials": "Pastelgintza Oinarrizkoak", + "pastry-essentials-desc": "Pastel eta postreerak egiteko osagaiak", + "bread-basics": "Ogi Oinarrizkoak", + "bread-basics-desc": "Ogi artisanalean beharrezko guztia", + "chocolate-specialties": "Txokolate Espezialiteak", + "chocolate-specialties-desc": "Txokolatezko produktuentzat" } }, "recipes": { @@ -153,7 +163,9 @@ }, "placeholders": { "name": "adib., Baguette, Croissant", - "finished_product": "Aukeratu produktu amaituak..." + "finished_product": "Aukeratu produktu amaituak...", + "yield_quantity": "10", + "ingredient_quantity": "Kant." }, "errors": { "name_required": "Errezeta izena beharrezkoa da", diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index e4312b21..27dca864 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -23,6 +23,8 @@ import aboutEs from './es/about.json'; import demoEs from './es/demo.json'; import blogEs from './es/blog.json'; import alertsEs from './es/alerts.json'; +import onboardingEs from './es/onboarding.json'; +import setupWizardEs from './es/setup_wizard.json'; // English translations import commonEn from './en/common.json'; @@ -49,6 +51,8 @@ import aboutEn from './en/about.json'; import demoEn from './en/demo.json'; import blogEn from './en/blog.json'; import alertsEn from './en/alerts.json'; +import onboardingEn from './en/onboarding.json'; +import setupWizardEn from './en/setup_wizard.json'; // Basque translations import commonEu from './eu/common.json'; @@ -75,6 +79,8 @@ import aboutEu from './eu/about.json'; import demoEu from './eu/demo.json'; import blogEu from './eu/blog.json'; import alertsEu from './eu/alerts.json'; +import onboardingEu from './eu/onboarding.json'; +import setupWizardEu from './eu/setup_wizard.json'; // Translation resources by language export const resources = { @@ -103,6 +109,8 @@ export const resources = { demo: demoEs, blog: blogEs, alerts: alertsEs, + onboarding: onboardingEs, + setup_wizard: setupWizardEs, }, en: { common: commonEn, @@ -129,6 +137,8 @@ export const resources = { demo: demoEn, blog: blogEn, alerts: alertsEn, + onboarding: onboardingEn, + setup_wizard: setupWizardEn, }, eu: { common: commonEu, @@ -155,6 +165,8 @@ export const resources = { demo: demoEu, blog: blogEu, alerts: alertsEu, + onboarding: onboardingEu, + setup_wizard: setupWizardEu, }, }; @@ -191,7 +203,7 @@ export const languageConfig = { }; // Namespaces available in translations -export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts'] as const; +export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'recipes', 'errors', 'dashboard', 'production', 'equipment', 'landing', 'settings', 'ajustes', 'reasoning', 'wizards', 'subscription', 'purchase_orders', 'help', 'features', 'about', 'demo', 'blog', 'alerts', 'onboarding', 'setup_wizard'] as const; export type Namespace = typeof namespaces[number]; // Helper function to get language display name diff --git a/services/inventory/app/api/ingredients.py b/services/inventory/app/api/ingredients.py index d2783b71..f866a5ee 100644 --- a/services/inventory/app/api/ingredients.py +++ b/services/inventory/app/api/ingredients.py @@ -21,6 +21,9 @@ from app.schemas.inventory import ( StockResponse, StockCreate, StockUpdate, + BulkIngredientCreate, + BulkIngredientResponse, + BulkIngredientResult, ) from shared.auth.decorators import get_current_user_dep from shared.auth.access_control import require_user_role, admin_role_required, owner_role_required @@ -157,6 +160,162 @@ async def create_ingredient( ) +@router.post( + route_builder.build_base_route("ingredients/bulk"), + response_model=BulkIngredientResponse, + status_code=status.HTTP_201_CREATED +) +@require_user_role(['admin', 'owner']) +async def bulk_create_ingredients( + bulk_data: BulkIngredientCreate, + tenant_id: UUID = Path(..., description="Tenant ID"), + current_user: dict = Depends(get_current_user_dep), + db: AsyncSession = Depends(get_db) +): + """Create multiple ingredients in a single transaction (Admin/Manager only)""" + import uuid + transaction_id = str(uuid.uuid4()) + + try: + # CRITICAL: Check subscription limit ONCE before creating any ingredients + from app.core.config import settings + total_requested = len(bulk_data.ingredients) + + async with httpx.AsyncClient(timeout=5.0) as client: + try: + # Check if we can add this many products + limit_check_response = await client.get( + f"{settings.TENANT_SERVICE_URL}/api/v1/tenants/subscriptions/{tenant_id}/can-add-products/{total_requested}", + headers={ + "x-user-id": str(current_user.get('user_id')), + "x-tenant-id": str(tenant_id) + } + ) + + if limit_check_response.status_code == 200: + limit_check = limit_check_response.json() + + if not limit_check.get('can_add', False): + logger.warning( + "Bulk product limit exceeded", + tenant_id=str(tenant_id), + requested=total_requested, + current=limit_check.get('current_count'), + max=limit_check.get('max_allowed'), + reason=limit_check.get('reason') + ) + raise HTTPException( + status_code=status.HTTP_402_PAYMENT_REQUIRED, + detail={ + "error": "product_limit_exceeded", + "message": limit_check.get('reason', 'Product limit exceeded'), + "requested": total_requested, + "current_count": limit_check.get('current_count'), + "max_allowed": limit_check.get('max_allowed'), + "upgrade_required": True + } + ) + else: + logger.warning( + "Failed to check product limit, allowing bulk creation", + tenant_id=str(tenant_id), + status_code=limit_check_response.status_code + ) + except httpx.TimeoutException: + logger.warning( + "Timeout checking product limit, allowing bulk creation", + tenant_id=str(tenant_id) + ) + except httpx.RequestError as e: + logger.warning( + "Error checking product limit, allowing bulk creation", + tenant_id=str(tenant_id), + error=str(e) + ) + + # Extract user ID - handle service tokens + raw_user_id = current_user.get('user_id') + if current_user.get('type') == 'service': + user_id = None + else: + try: + user_id = UUID(raw_user_id) + except (ValueError, TypeError): + user_id = None + + # Create all ingredients + service = InventoryService() + results: List[BulkIngredientResult] = [] + total_created = 0 + total_failed = 0 + + for index, ingredient_data in enumerate(bulk_data.ingredients): + try: + ingredient = await service.create_ingredient(ingredient_data, tenant_id, user_id) + results.append(BulkIngredientResult( + index=index, + success=True, + ingredient=IngredientResponse.from_orm(ingredient), + error=None + )) + total_created += 1 + + logger.debug( + "Ingredient created in bulk operation", + tenant_id=str(tenant_id), + ingredient_id=str(ingredient.id), + ingredient_name=ingredient.name, + index=index, + transaction_id=transaction_id + ) + except Exception as e: + results.append(BulkIngredientResult( + index=index, + success=False, + ingredient=None, + error=str(e) + )) + total_failed += 1 + + logger.warning( + "Failed to create ingredient in bulk operation", + tenant_id=str(tenant_id), + index=index, + error=str(e), + transaction_id=transaction_id + ) + + logger.info( + "Bulk ingredient creation completed", + tenant_id=str(tenant_id), + total_requested=total_requested, + total_created=total_created, + total_failed=total_failed, + transaction_id=transaction_id + ) + + return BulkIngredientResponse( + total_requested=total_requested, + total_created=total_created, + total_failed=total_failed, + results=results, + transaction_id=transaction_id + ) + except HTTPException: + raise + except Exception as e: + logger.error( + "Failed to process bulk ingredient creation", + tenant_id=str(tenant_id), + error=str(e), + transaction_id=transaction_id + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to process bulk ingredient creation" + ) + + @router.get( route_builder.build_base_route("ingredients/count"), response_model=dict diff --git a/services/inventory/app/schemas/inventory.py b/services/inventory/app/schemas/inventory.py index 9fb5bbc5..df2be6f4 100644 --- a/services/inventory/app/schemas/inventory.py +++ b/services/inventory/app/schemas/inventory.py @@ -171,6 +171,30 @@ class IngredientResponse(InventoryBaseSchema): return None +# ===== BULK INGREDIENT SCHEMAS ===== + +class BulkIngredientCreate(InventoryBaseSchema): + """Schema for bulk creating ingredients""" + ingredients: List[IngredientCreate] = Field(..., description="List of ingredients to create") + + +class BulkIngredientResult(InventoryBaseSchema): + """Schema for individual result in bulk operation""" + index: int = Field(..., description="Index of the ingredient in the original request") + success: bool = Field(..., description="Whether the creation succeeded") + ingredient: Optional[IngredientResponse] = Field(None, description="Created ingredient (if successful)") + error: Optional[str] = Field(None, description="Error message (if failed)") + + +class BulkIngredientResponse(InventoryBaseSchema): + """Schema for bulk ingredient creation response""" + total_requested: int = Field(..., description="Total number of ingredients requested") + total_created: int = Field(..., description="Number of ingredients successfully created") + total_failed: int = Field(..., description="Number of ingredients that failed") + results: List[BulkIngredientResult] = Field(..., description="Detailed results for each ingredient") + transaction_id: str = Field(..., description="Transaction ID for audit trail") + + # ===== STOCK SCHEMAS ===== class StockCreate(InventoryBaseSchema):