Add traslations

This commit is contained in:
Urtzi Alfaro
2025-12-25 18:35:37 +01:00
parent 82567b8701
commit b95b86ee2c
18 changed files with 516 additions and 52 deletions

View File

@@ -8,6 +8,7 @@ import {
IngredientCreate,
IngredientUpdate,
IngredientResponse,
BulkIngredientResponse,
StockCreate,
StockUpdate,
StockResponse,
@@ -253,6 +254,22 @@ export const useCreateIngredient = (
});
};
export const useBulkCreateIngredients = (
options?: UseMutationOptions<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>
) => {
const queryClient = useQueryClient();
return useMutation<BulkIngredientResponse, ApiError, { tenantId: string; ingredients: IngredientCreate[] }>({
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,

View File

@@ -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<BulkIngredientResponse> {
return apiClient.post<BulkIngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/bulk`,
{ ingredients }
);
}
async getIngredient(tenantId: string, ingredientId: string): Promise<IngredientResponse> {
return apiClient.get<IngredientResponse>(
`${this.baseUrl}/${tenantId}/inventory/ingredients/${ingredientId}`

View File

@@ -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

View File

@@ -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);

View File

@@ -58,7 +58,7 @@ export const CompletionStep: React.FC<CompletionStepProps> = ({
{/* Success Message */}
<div className="space-y-5 animate-slide-up">
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-success)] to-[var(--color-primary)] bg-clip-text text-transparent animate-shimmer" style={{ backgroundSize: '200% auto' }}>
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-[var(--color-primary)] via-[var(--color-success)] to-[var(--color-primary)] bg-clip-text text-transparent" style={{ backgroundSize: '200% auto' }}>
{t('onboarding:completion.congratulations', '¡Felicidades! Tu Sistema Está Listo')}
</h1>
<p className="text-lg md:text-xl text-[var(--text-secondary)] max-w-3xl mx-auto leading-relaxed">

View File

@@ -57,27 +57,30 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
// 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');

View File

@@ -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<InventoryReviewStepProps> = ({
// 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<InventoryReviewStepProps> = ({
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<InventoryReviewStepProps> = ({
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-[var(--text-primary)] mb-1">
{template.name}
{t(`setup_wizard:inventory.templates.${template.id}`, template.name)}
</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">
{template.description}
{t(`setup_wizard:inventory.templates.${template.id}-desc`, template.description)}
</p>
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
{template.items.length} {t('inventory:templates.items', 'ingredientes')}

View File

@@ -592,7 +592,7 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ 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 && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.yield_quantity}</p>}
</div>
@@ -666,7 +666,7 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ 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`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_quantity`]}</p>}

View File

@@ -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."

View File

@@ -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",

View File

@@ -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."

View File

@@ -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",

View File

@@ -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",

View File

@@ -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."

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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):