From 105410c9d31e92259f837129f9c5277a870d4c8f Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Fri, 19 Sep 2025 11:44:38 +0200 Subject: [PATCH] Add order page with real API calls --- frontend/src/api/client/apiClient.ts | 32 +- frontend/src/api/types/orders.ts | 92 +- .../domain/orders/OrderFormModal.tsx | 695 +++++++++++ .../src/components/domain/orders/index.ts | 1 + .../components/ui/StatusCard/StatusCard.tsx | 58 +- frontend/src/contexts/AuthContext.tsx | 47 +- frontend/src/locales/es/orders.json | 106 ++ frontend/src/locales/index.ts | 6 +- .../app/operations/orders/OrdersPage.tsx | 1092 ++++++++++++----- .../procurement/ProcurementPage.tsx | 60 +- .../app/operations/recipes/RecipesPage.tsx | 8 +- .../operations/suppliers/SuppliersPage.tsx | 10 +- .../src/pages/app/settings/team/TeamPage.tsx | 8 +- frontend/src/stores/auth.store.ts | 17 +- frontend/src/utils/enumHelpers.ts | 142 +++ scripts/seed_orders_test_data.sh | 27 + services/orders/app/api/orders.py | 3 +- services/orders/app/models/enums.py | 152 +++ .../app/repositories/order_repository.py | 78 +- services/orders/app/schemas/order_schemas.py | 87 +- .../orders/app/services/orders_service.py | 8 +- services/orders/scripts/seed_test_data.py | 290 +++++ 22 files changed, 2556 insertions(+), 463 deletions(-) create mode 100644 frontend/src/components/domain/orders/OrderFormModal.tsx create mode 100644 frontend/src/components/domain/orders/index.ts create mode 100644 frontend/src/locales/es/orders.json create mode 100755 scripts/seed_orders_test_data.sh create mode 100644 services/orders/app/models/enums.py create mode 100644 services/orders/scripts/seed_test_data.py diff --git a/frontend/src/api/client/apiClient.ts b/frontend/src/api/client/apiClient.ts index 29e1ff93..ff1b93fd 100644 --- a/frontend/src/api/client/apiClient.ts +++ b/frontend/src/api/client/apiClient.ts @@ -25,6 +25,9 @@ class ApiClient { private tenantId: string | null = null; private refreshToken: string | null = null; private isRefreshing: boolean = false; + private refreshAttempts: number = 0; + private maxRefreshAttempts: number = 3; + private lastRefreshAttempt: number = 0; private failedQueue: Array<{ resolve: (value?: any) => void; reject: (error?: any) => void; @@ -72,6 +75,14 @@ class ApiClient { // Check if error is 401 and we have a refresh token if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) { + // Check if we've exceeded max refresh attempts in a short time + const now = Date.now(); + if (this.refreshAttempts >= this.maxRefreshAttempts && (now - this.lastRefreshAttempt) < 30000) { + console.log('Max refresh attempts exceeded, logging out'); + await this.handleAuthFailure(); + return Promise.reject(this.handleError(error)); + } + if (this.isRefreshing) { // If already refreshing, queue this request return new Promise((resolve, reject) => { @@ -81,8 +92,12 @@ class ApiClient { originalRequest._retry = true; this.isRefreshing = true; + this.refreshAttempts++; + this.lastRefreshAttempt = now; try { + console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`); + // Attempt to refresh the token const response = await this.client.post('/auth/refresh', { refresh_token: this.refreshToken @@ -90,6 +105,11 @@ class ApiClient { const { access_token, refresh_token } = response.data; + console.log('Token refresh successful'); + + // Reset refresh attempts on success + this.refreshAttempts = 0; + // Update tokens this.setAuthToken(access_token); if (refresh_token) { @@ -107,6 +127,7 @@ class ApiClient { return this.client(originalRequest); } catch (refreshError) { + console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError); // Refresh failed, clear tokens and redirect to login this.processQueue(refreshError, null); await this.handleAuthFailure(); @@ -165,13 +186,14 @@ class ApiClient { try { // Dynamically import to avoid circular dependency const { useAuthStore } = await import('../../stores/auth.store'); - const store = useAuthStore.getState(); + const setState = useAuthStore.setState; // Update the store with new tokens - store.token = accessToken; - if (refreshToken) { - store.refreshToken = refreshToken; - } + setState(state => ({ + ...state, + token: accessToken, + refreshToken: refreshToken || state.refreshToken, + })); } catch (error) { console.warn('Failed to update auth store:', error); } diff --git a/frontend/src/api/types/orders.ts b/frontend/src/api/types/orders.ts index a1774f6f..2c6cf25f 100644 --- a/frontend/src/api/types/orders.ts +++ b/frontend/src/api/types/orders.ts @@ -3,18 +3,86 @@ * Based on backend schemas in services/orders/app/schemas/order_schemas.py */ -export type CustomerType = 'individual' | 'business' | 'central_bakery'; -export type DeliveryMethod = 'delivery' | 'pickup'; -export type PaymentTerms = 'immediate' | 'net_30' | 'net_60'; -export type PaymentMethod = 'cash' | 'card' | 'bank_transfer' | 'account'; -export type PaymentStatus = 'pending' | 'partial' | 'paid' | 'failed' | 'refunded'; -export type CustomerSegment = 'vip' | 'regular' | 'wholesale'; -export type PriorityLevel = 'high' | 'normal' | 'low'; -export type OrderType = 'standard' | 'rush' | 'recurring' | 'special'; -export type OrderStatus = 'pending' | 'confirmed' | 'in_production' | 'ready' | 'out_for_delivery' | 'delivered' | 'cancelled' | 'failed'; -export type OrderSource = 'manual' | 'online' | 'phone' | 'app' | 'api'; -export type SalesChannel = 'direct' | 'wholesale' | 'retail'; -export type BusinessModel = 'individual_bakery' | 'central_bakery'; +export enum CustomerType { + INDIVIDUAL = 'individual', + BUSINESS = 'business', + CENTRAL_BAKERY = 'central_bakery' +} + +export enum DeliveryMethod { + DELIVERY = 'delivery', + PICKUP = 'pickup' +} + +export enum PaymentTerms { + IMMEDIATE = 'immediate', + NET_30 = 'net_30', + NET_60 = 'net_60' +} + +export enum PaymentMethod { + CASH = 'cash', + CARD = 'card', + BANK_TRANSFER = 'bank_transfer', + ACCOUNT = 'account' +} + +export enum PaymentStatus { + PENDING = 'pending', + PARTIAL = 'partial', + PAID = 'paid', + FAILED = 'failed', + REFUNDED = 'refunded' +} + +export enum CustomerSegment { + VIP = 'vip', + REGULAR = 'regular', + WHOLESALE = 'wholesale' +} + +export enum PriorityLevel { + HIGH = 'high', + NORMAL = 'normal', + LOW = 'low' +} + +export enum OrderType { + STANDARD = 'standard', + RUSH = 'rush', + RECURRING = 'recurring', + SPECIAL = 'special' +} + +export enum OrderStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + IN_PRODUCTION = 'in_production', + READY = 'ready', + OUT_FOR_DELIVERY = 'out_for_delivery', + DELIVERED = 'delivered', + CANCELLED = 'cancelled', + FAILED = 'failed' +} + +export enum OrderSource { + MANUAL = 'manual', + ONLINE = 'online', + PHONE = 'phone', + APP = 'app', + API = 'api' +} + +export enum SalesChannel { + DIRECT = 'direct', + WHOLESALE = 'wholesale', + RETAIL = 'retail' +} + +export enum BusinessModel { + INDIVIDUAL_BAKERY = 'individual_bakery', + CENTRAL_BAKERY = 'central_bakery' +} // ===== Customer Types ===== diff --git a/frontend/src/components/domain/orders/OrderFormModal.tsx b/frontend/src/components/domain/orders/OrderFormModal.tsx new file mode 100644 index 00000000..1cfceebe --- /dev/null +++ b/frontend/src/components/domain/orders/OrderFormModal.tsx @@ -0,0 +1,695 @@ +import React, { useState, useEffect } from 'react'; +import { X, Plus, Minus, User, ShoppingCart, FileText, Calculator, Package } from 'lucide-react'; +import { + Button, + Input, + Select, + Card, + Badge +} from '../../ui'; +import { StatusModal } from '../../ui/StatusModal'; +import type { StatusModalSection } from '../../ui/StatusModal'; +import { + OrderCreate, + OrderItemCreate, + CustomerResponse, + OrderType, + PriorityLevel, + DeliveryMethod, + PaymentMethod, + PaymentTerms, + OrderSource, + SalesChannel, + CustomerCreate, + CustomerType, + CustomerSegment +} from '../../../api/types/orders'; +import { useCustomers, useCreateCustomer } from '../../../api/hooks/orders'; +import { useIngredients } from '../../../api/hooks/inventory'; +import { ProductType, ProductCategory } from '../../../api/types/inventory'; +import { useCurrentTenant } from '../../../stores/tenant.store'; +import { useAuthUser } from '../../../stores/auth.store'; +import { useOrderEnums } from '../../../utils/enumHelpers'; + +interface OrderFormModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (orderData: OrderCreate) => Promise; +} + + +export const OrderFormModal: React.FC = ({ + isOpen, + onClose, + onSave +}) => { + const currentTenant = useCurrentTenant(); + const user = useAuthUser(); + const tenantId = currentTenant?.id || user?.tenant_id || ''; + const orderEnums = useOrderEnums(); + + // Form state + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [orderItems, setOrderItems] = useState([]); + const [orderData, setOrderData] = useState>({ + order_type: OrderType.STANDARD, + priority: PriorityLevel.NORMAL, + delivery_method: DeliveryMethod.PICKUP, + discount_percentage: 0, + delivery_fee: 0, + payment_terms: PaymentTerms.IMMEDIATE, + order_source: OrderSource.MANUAL, + sales_channel: SalesChannel.DIRECT + }); + + // Customer modals + const [showCustomerForm, setShowCustomerForm] = useState(false); + const [showCustomerSelector, setShowCustomerSelector] = useState(false); + const [newCustomerData, setNewCustomerData] = useState>({ + customer_type: CustomerType.INDIVIDUAL, + country: 'España', + is_active: true, + preferred_delivery_method: DeliveryMethod.PICKUP, + payment_terms: PaymentTerms.IMMEDIATE, + discount_percentage: 0, + customer_segment: CustomerSegment.REGULAR, + priority_level: PriorityLevel.NORMAL + }); + + // Product selection + const [showProductModal, setShowProductModal] = useState(false); + const [productSearch, setProductSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(''); + + // API hooks + const { data: customers = [] } = useCustomers({ + tenant_id: tenantId, + active_only: true, + limit: 100 + }); + + // Fetch finished products from inventory + const { data: finishedProducts = [], isLoading: productsLoading } = useIngredients( + tenantId, + { + product_type: ProductType.FINISHED_PRODUCT, + is_active: true + } + ); + + const createCustomerMutation = useCreateCustomer(); + + // Calculate totals + const subtotal = orderItems.reduce((sum, item) => sum + (item.quantity * item.unit_price), 0); + const discountAmount = subtotal * (orderData.discount_percentage || 0) / 100; + const taxAmount = (subtotal - discountAmount) * 0.21; // 21% VAT + const total = subtotal - discountAmount + taxAmount + (orderData.delivery_fee || 0); + + useEffect(() => { + if (!isOpen) { + // Reset form when modal closes + setSelectedCustomer(null); + setOrderItems([]); + setProductSearch(''); + setSelectedCategory(''); + setOrderData({ + order_type: OrderType.STANDARD, + priority: PriorityLevel.NORMAL, + delivery_method: DeliveryMethod.PICKUP, + discount_percentage: 0, + delivery_fee: 0, + payment_terms: PaymentTerms.IMMEDIATE, + order_source: OrderSource.MANUAL, + sales_channel: SalesChannel.DIRECT + }); + } + }, [isOpen]); + + const handleAddProduct = (product: any) => { + const existingItem = orderItems.find(item => item.product_id === product.id); + + if (existingItem) { + setOrderItems(items => items.map(item => + item.product_id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + )); + } else { + const newItem: OrderItemCreate = { + product_id: product.id, + product_name: product.name, + product_sku: product.sku || undefined, + product_category: product.category || undefined, + quantity: 1, + unit_of_measure: product.unit_of_measure || 'unidad', + unit_price: product.average_cost || product.standard_cost || 0, + line_discount: 0 + }; + setOrderItems(items => [...items, newItem]); + } + setShowProductModal(false); + }; + + const handleUpdateItemQuantity = (productId: string, quantity: number) => { + if (quantity <= 0) { + setOrderItems(items => items.filter(item => item.product_id !== productId)); + } else { + setOrderItems(items => items.map(item => + item.product_id === productId + ? { ...item, quantity } + : item + )); + } + }; + + const handleCreateCustomer = async () => { + if (!newCustomerData.name || !tenantId) return; + + try { + const customerData: CustomerCreate = { + ...newCustomerData, + tenant_id: tenantId, + customer_code: `CUST-${Date.now()}` // Generate simple code + } as CustomerCreate; + + const newCustomer = await createCustomerMutation.mutateAsync(customerData); + setSelectedCustomer(newCustomer); + setShowCustomerForm(false); + setNewCustomerData({ + customer_type: CustomerType.INDIVIDUAL, + country: 'España', + is_active: true, + preferred_delivery_method: DeliveryMethod.PICKUP, + payment_terms: PaymentTerms.IMMEDIATE, + discount_percentage: 0, + customer_segment: CustomerSegment.REGULAR, + priority_level: PriorityLevel.NORMAL + }); + } catch (error) { + console.error('Error creating customer:', error); + } + }; + + const handleSaveOrder = async () => { + if (!selectedCustomer || orderItems.length === 0 || !tenantId) return; + + const finalOrderData: OrderCreate = { + tenant_id: tenantId, + customer_id: selectedCustomer.id, + order_type: orderData.order_type || OrderType.STANDARD, + priority: orderData.priority || PriorityLevel.NORMAL, + requested_delivery_date: orderData.requested_delivery_date || new Date().toISOString(), + delivery_method: orderData.delivery_method || DeliveryMethod.PICKUP, + delivery_fee: orderData.delivery_fee || 0, + discount_percentage: orderData.discount_percentage || 0, + payment_terms: orderData.payment_terms || PaymentTerms.IMMEDIATE, + order_source: orderData.order_source || OrderSource.MANUAL, + sales_channel: orderData.sales_channel || SalesChannel.DIRECT, + items: orderItems, + special_instructions: orderData.special_instructions + }; + + await onSave(finalOrderData); + onClose(); + }; + + // Get unique categories for filtering + const uniqueCategories = Array.from(new Set( + finishedProducts + .map(product => product.category) + .filter(Boolean) + )).sort(); + + const filteredProducts = finishedProducts.filter(product => { + const matchesSearch = product.name.toLowerCase().includes(productSearch.toLowerCase()) || + (product.category && product.category.toLowerCase().includes(productSearch.toLowerCase())) || + (product.description && product.description.toLowerCase().includes(productSearch.toLowerCase())); + + const matchesCategory = !selectedCategory || product.category === selectedCategory; + + return matchesSearch && matchesCategory; + }); + + // Convert form data to StatusModal sections + const modalSections: StatusModalSection[] = [ + { + title: 'Cliente', + icon: User, + fields: [ + { + label: 'Cliente Seleccionado', + value: selectedCustomer ? `${selectedCustomer.name} (${selectedCustomer.customer_code})` : 'Ninguno', + span: 2 + } + ] + }, + { + title: 'Detalles del Pedido', + icon: FileText, + fields: [ + { + label: 'Tipo de Pedido', + value: orderData.order_type || OrderType.STANDARD, + type: 'select', + editable: true, + options: orderEnums.getOrderTypeOptions() + }, + { + label: 'Prioridad', + value: orderData.priority || PriorityLevel.NORMAL, + type: 'select', + editable: true, + options: orderEnums.getPriorityLevelOptions() + }, + { + label: 'Método de Entrega', + value: orderData.delivery_method || DeliveryMethod.PICKUP, + type: 'select', + editable: true, + options: orderEnums.getDeliveryMethodOptions() + }, + { + label: 'Fecha de Entrega', + value: orderData.requested_delivery_date?.split('T')[0] || '', + type: 'date', + editable: true + } + ] + }, + { + title: 'Productos', + icon: Package, + fields: [ + { + label: 'Total de Productos', + value: orderItems.length, + highlight: true + }, + { + label: 'Lista de Productos', + value: orderItems.length > 0 ? orderItems.map(item => `${item.product_name} (x${item.quantity})`).join(', ') : 'Sin productos', + span: 2 + } + ] + }, + { + title: 'Resumen Financiero', + icon: Calculator, + fields: [ + { + label: 'Subtotal', + value: subtotal, + type: 'currency', + highlight: true + }, + { + label: 'Descuento', + value: -discountAmount, + type: 'currency' + }, + { + label: 'IVA (21%)', + value: taxAmount, + type: 'currency' + }, + { + label: 'Gastos de Envío', + value: orderData.delivery_fee || 0, + type: 'currency', + editable: true + }, + { + label: 'Total', + value: total, + type: 'currency', + highlight: true, + span: 2 + } + ] + } + ]; + + const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => { + const section = modalSections[sectionIndex]; + const field = section.fields[fieldIndex]; + + // Update order data based on field changes + if (section.title === 'Detalles del Pedido') { + if (field.label === 'Tipo de Pedido') { + setOrderData(prev => ({ ...prev, order_type: value as OrderType })); + } else if (field.label === 'Prioridad') { + setOrderData(prev => ({ ...prev, priority: value as PriorityLevel })); + } else if (field.label === 'Método de Entrega') { + setOrderData(prev => ({ ...prev, delivery_method: value as DeliveryMethod })); + } else if (field.label === 'Fecha de Entrega') { + setOrderData(prev => ({ ...prev, requested_delivery_date: value ? `${value}T12:00:00Z` : undefined })); + } + } else if (section.title === 'Resumen Financiero' && field.label === 'Gastos de Envío') { + setOrderData(prev => ({ ...prev, delivery_fee: Number(value) })); + } + }; + + return ( + <> + setShowCustomerSelector(true) + }, + { + label: 'Agregar Productos', + variant: 'outline', + onClick: () => setShowProductModal(true), + icon: Plus + }, + { + label: 'Cancelar', + variant: 'outline', + onClick: onClose + }, + { + label: 'Crear Pedido', + variant: 'primary', + onClick: handleSaveOrder, + disabled: !selectedCustomer || orderItems.length === 0 + } + ]} + onFieldChange={handleFieldChange} + /> + + {/* Legacy content for sections that need custom UI */} +
+ +
+ + {/* Product Selection Modal - Using StatusModal for consistency */} + setShowProductModal(false)} + mode="view" + title="Seleccionar Productos" + subtitle="Elija productos terminados para agregar al pedido" + size="2xl" + showDefaultActions={false} + sections={[ + { + title: 'Filtros', + icon: Package, + fields: [ + { + label: 'Buscar productos', + value: productSearch, + type: 'text', + editable: true, + placeholder: 'Buscar productos...', + span: 1 + }, + { + label: 'Categoría', + value: selectedCategory, + type: 'select', + editable: true, + placeholder: 'Todas las categorías', + options: [ + { value: '', label: 'Todas las categorías' }, + ...uniqueCategories.map(category => ({ + value: category, + label: category + })) + ], + span: 1 + } + ] + }, + { + title: 'Productos Disponibles', + icon: ShoppingCart, + fields: [ + { + label: 'Lista de productos', + value: ( +
+ {productsLoading ? ( +
+

Cargando productos...

+
+ ) : filteredProducts.length === 0 ? ( +
+

+ {productSearch ? 'No se encontraron productos que coincidan con la búsqueda' : 'No hay productos finalizados disponibles'} +

+
+ ) : ( + filteredProducts.map(product => ( +
+
+
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+ {product.category && ( + + {product.category} + + )} + + €{(product.average_cost || product.standard_cost || 0).toFixed(2)} + +
+
+ + +
+ +
+ Stock: {product.total_quantity || 0} {product.unit_of_measure} + {product.sku && SKU: {product.sku}} +
+
+ )) + )} +
+ ), + span: 2 + } + ] + } + ]} + onFieldChange={(sectionIndex, fieldIndex, value) => { + if (sectionIndex === 0) { + if (fieldIndex === 0) { + setProductSearch(String(value)); + } else if (fieldIndex === 1) { + setSelectedCategory(String(value)); + } + } + }} + /> + + {/* New Customer Modal - Using StatusModal for consistency */} + setShowCustomerForm(false)} + mode="edit" + title="Nuevo Cliente" + subtitle="Complete la información del cliente" + size="lg" + showDefaultActions={false} + sections={[ + { + title: 'Información Personal', + icon: User, + fields: [ + { + label: 'Nombre *', + value: newCustomerData.name || '', + type: 'text', + editable: true, + required: true, + placeholder: 'Nombre del cliente' + }, + { + label: 'Teléfono', + value: newCustomerData.phone || '', + type: 'tel', + editable: true, + placeholder: 'Número de teléfono' + }, + { + label: 'Email', + value: newCustomerData.email || '', + type: 'email', + editable: true, + placeholder: 'Correo electrónico', + span: 2 + }, + { + label: 'Tipo de Cliente', + value: newCustomerData.customer_type || CustomerType.INDIVIDUAL, + type: 'select', + editable: true, + options: orderEnums.getCustomerTypeOptions() + }, + { + label: 'Método de Entrega Preferido', + value: newCustomerData.preferred_delivery_method || DeliveryMethod.PICKUP, + type: 'select', + editable: true, + options: orderEnums.getDeliveryMethodOptions() + } + ] + } + ]} + actions={[ + { + label: 'Cancelar', + variant: 'outline', + onClick: () => setShowCustomerForm(false) + }, + { + label: 'Crear Cliente', + variant: 'primary', + onClick: handleCreateCustomer, + disabled: !newCustomerData.name + } + ]} + onFieldChange={(sectionIndex, fieldIndex, value) => { + // Get the customer modal sections instead of the order modal sections + const customerModalSections = [ + { + title: 'Información Personal', + icon: User, + fields: [ + { label: 'Nombre *' }, + { label: 'Teléfono' }, + { label: 'Email' }, + { label: 'Tipo de Cliente' }, + { label: 'Método de Entrega Preferido' } + ] + } + ]; + + const field = customerModalSections[sectionIndex]?.fields[fieldIndex]; + if (!field) return; + + if (field.label === 'Nombre *') { + setNewCustomerData(prev => ({ ...prev, name: String(value) })); + } else if (field.label === 'Teléfono') { + setNewCustomerData(prev => ({ ...prev, phone: String(value) })); + } else if (field.label === 'Email') { + setNewCustomerData(prev => ({ ...prev, email: String(value) })); + } else if (field.label === 'Tipo de Cliente') { + setNewCustomerData(prev => ({ ...prev, customer_type: value as CustomerType })); + } else if (field.label === 'Método de Entrega Preferido') { + setNewCustomerData(prev => ({ ...prev, preferred_delivery_method: value as DeliveryMethod })); + } + }} + /> + + {/* Customer Selector Modal */} + setShowCustomerSelector(false)} + mode="view" + title="Seleccionar Cliente" + subtitle="Elija un cliente existente o cree uno nuevo" + size="lg" + showDefaultActions={false} + sections={[ + { + title: 'Clientes Disponibles', + icon: User, + fields: [ + { + label: 'Lista de clientes', + value: ( +
+ {customers.length === 0 ? ( +
+

No hay clientes disponibles

+ +
+ ) : ( + customers.map(customer => ( +
+
+
+

{customer.name}

+

+ Código: {customer.customer_code} +

+
+ {customer.email &&
📧 {customer.email}
} + {customer.phone &&
📞 {customer.phone}
} +
+
+ +
+
+ )) + )} +
+ ), + span: 2 + } + ] + } + ]} + actions={[ + { + label: 'Nuevo Cliente', + variant: 'outline', + onClick: () => { + setShowCustomerSelector(false); + setShowCustomerForm(true); + } + }, + { + label: 'Cerrar', + variant: 'primary', + onClick: () => setShowCustomerSelector(false) + } + ]} + /> + + ); +}; + +export default OrderFormModal; \ No newline at end of file diff --git a/frontend/src/components/domain/orders/index.ts b/frontend/src/components/domain/orders/index.ts new file mode 100644 index 00000000..d391235e --- /dev/null +++ b/frontend/src/components/domain/orders/index.ts @@ -0,0 +1 @@ +export { default as OrderFormModal } from './OrderFormModal'; \ No newline at end of file diff --git a/frontend/src/components/ui/StatusCard/StatusCard.tsx b/frontend/src/components/ui/StatusCard/StatusCard.tsx index 087b19bf..fed81f28 100644 --- a/frontend/src/components/ui/StatusCard/StatusCard.tsx +++ b/frontend/src/components/ui/StatusCard/StatusCard.tsx @@ -110,12 +110,12 @@ export const StatusCard: React.FC = ({ return ( = ({ }} onClick={onClick} > -
+
{/* Header with status indicator */}
{StatusIcon && ( )}
-
+
{title}
= ({ )}
-
-
+
+
{primaryValue}
{primaryValueLabel && ( -
+
{primaryValueLabel}
)}
- {/* Secondary info */} + {/* Secondary info - Mobile optimized */} {secondaryInfo && ( -
- +
+ {secondaryInfo.label} - + {secondaryInfo.value}
@@ -228,36 +228,36 @@ export const StatusCard: React.FC = ({ )} - {/* Metadata */} + {/* Metadata - Improved mobile layout */} {metadata.length > 0 && (
{metadata.map((item, index) => ( -
{item}
+
{item}
))}
)} - {/* Simplified Action System */} + {/* Simplified Action System - Mobile optimized */} {actions.length > 0 && ( -
+
{/* All actions in a clean horizontal layout */} -
+
{/* Primary action as a subtle text button */} {primaryActions.length > 0 && ( )} @@ -269,14 +269,14 @@ export const StatusCard: React.FC = ({ onClick={action.onClick} title={action.label} className={` - p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 + p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 ${action.destructive ? 'text-red-500 hover:bg-red-50 hover:text-red-600' : 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-secondary)]' } `} > - {action.icon && React.createElement(action.icon, { className: "w-4 h-4" })} + {action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })} ))} @@ -287,14 +287,14 @@ export const StatusCard: React.FC = ({ onClick={action.onClick} title={action.label} className={` - p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 + p-1.5 sm:p-2 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 ${action.destructive ? 'text-red-500 hover:bg-red-50 hover:text-red-600' : 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]' } `} > - {action.icon && React.createElement(action.icon, { className: "w-4 h-4" })} + {action.icon && React.createElement(action.icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })} ))}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d9432f22..c6380cb4 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ -import React, { createContext, useContext, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useEffect, ReactNode, useState } from 'react'; import { useAuthStore, User } from '../stores/auth.store'; +import { authService } from '../api/services/auth'; interface AuthContextType { user: User | null; @@ -31,29 +32,43 @@ interface AuthProviderProps { export const AuthProvider: React.FC = ({ children }) => { const authStore = useAuthStore(); + const [isInitializing, setIsInitializing] = useState(true); // Initialize auth on mount useEffect(() => { const initializeAuth = async () => { + setIsInitializing(true); + + // Wait a bit for zustand persist to rehydrate + await new Promise(resolve => setTimeout(resolve, 100)); + // Check if we have stored auth data - const storedAuth = localStorage.getItem('auth-storage'); - if (storedAuth) { + if (authStore.token && authStore.refreshToken) { try { - const { state } = JSON.parse(storedAuth); - if (state.token && state.user) { - // Validate token by attempting to refresh - try { - await authStore.refreshAuth(); - } catch (error) { - // Token is invalid, clear auth - authStore.logout(); - } - } + // Validate current token by trying to verify it + await authService.verifyToken(); + console.log('Token is valid, user authenticated'); } catch (error) { - console.error('Error parsing stored auth:', error); - authStore.logout(); + console.log('Token expired, attempting refresh...'); + // Token is invalid, try to refresh + try { + await authStore.refreshAuth(); + console.log('Token refreshed successfully'); + } catch (refreshError) { + console.log('Token refresh failed, logging out:', refreshError); + // Refresh failed, clear auth + authStore.logout(); + } } + } else if (authStore.isAuthenticated) { + // User is marked as authenticated but no tokens, logout + console.log('No tokens found but user marked as authenticated, logging out'); + authStore.logout(); + } else { + console.log('No stored auth data found'); } + + setIsInitializing(false); }; initializeAuth(); @@ -63,7 +78,7 @@ export const AuthProvider: React.FC = ({ children }) => { const contextValue: AuthContextType = { user: authStore.user, isAuthenticated: authStore.isAuthenticated, - isLoading: authStore.isLoading, + isLoading: authStore.isLoading || isInitializing, error: authStore.error, login: authStore.login, logout: authStore.logout, diff --git a/frontend/src/locales/es/orders.json b/frontend/src/locales/es/orders.json new file mode 100644 index 00000000..aaa72e95 --- /dev/null +++ b/frontend/src/locales/es/orders.json @@ -0,0 +1,106 @@ +{ + "customer_types": { + "individual": "Individual", + "business": "Empresa", + "central_bakery": "Panadería Central" + }, + "delivery_methods": { + "pickup": "Recogida", + "delivery": "Entrega a domicilio" + }, + "payment_terms": { + "immediate": "Inmediato", + "net_30": "Neto 30 días", + "net_60": "Neto 60 días" + }, + "payment_methods": { + "cash": "Efectivo", + "card": "Tarjeta", + "bank_transfer": "Transferencia bancaria", + "account": "Cuenta" + }, + "payment_status": { + "pending": "Pendiente", + "partial": "Parcial", + "paid": "Pagado", + "failed": "Fallido", + "refunded": "Reembolsado" + }, + "customer_segments": { + "regular": "Regular", + "vip": "VIP", + "wholesale": "Mayorista" + }, + "priority_levels": { + "low": "Baja", + "normal": "Normal", + "high": "Alta" + }, + "order_types": { + "standard": "Estándar", + "rush": "Urgente", + "recurring": "Recurrente", + "special": "Especial" + }, + "order_status": { + "pending": "Pendiente", + "confirmed": "Confirmado", + "in_production": "En Producción", + "ready": "Listo", + "out_for_delivery": "En Reparto", + "delivered": "Entregado", + "cancelled": "Cancelado", + "failed": "Fallido" + }, + "order_sources": { + "manual": "Manual", + "online": "En línea", + "phone": "Teléfono", + "app": "Aplicación", + "api": "API" + }, + "sales_channels": { + "direct": "Directo", + "wholesale": "Mayorista", + "retail": "Minorista" + }, + "labels": { + "name": "Nombre", + "business_name": "Nombre Comercial", + "customer_code": "Código de Cliente", + "email": "Email", + "phone": "Teléfono", + "address": "Dirección", + "city": "Ciudad", + "state": "Estado/Provincia", + "postal_code": "Código Postal", + "country": "País", + "customer_type": "Tipo de Cliente", + "delivery_method": "Método de Entrega", + "payment_terms": "Términos de Pago", + "payment_method": "Método de Pago", + "payment_status": "Estado del Pago", + "customer_segment": "Segmento de Cliente", + "priority_level": "Nivel de Prioridad", + "order_type": "Tipo de Pedido", + "order_status": "Estado del Pedido", + "order_source": "Origen del Pedido", + "sales_channel": "Canal de Ventas", + "order_number": "Número de Pedido", + "order_date": "Fecha del Pedido", + "delivery_date": "Fecha de Entrega", + "total_amount": "Importe Total", + "subtotal": "Subtotal", + "discount": "Descuento", + "tax": "Impuestos", + "shipping": "Envío", + "special_instructions": "Instrucciones Especiales" + }, + "descriptions": { + "customer_type": "Tipo de cliente para determinar precios y términos comerciales", + "delivery_method": "Método preferido para la entrega de pedidos", + "payment_terms": "Términos y condiciones de pago acordados", + "customer_segment": "Segmentación del cliente para ofertas personalizadas", + "priority_level": "Nivel de prioridad para el procesamiento de pedidos" + } +} \ No newline at end of file diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts index 2c056e2a..6af17179 100644 --- a/frontend/src/locales/index.ts +++ b/frontend/src/locales/index.ts @@ -4,6 +4,7 @@ import authEs from './es/auth.json'; import inventoryEs from './es/inventory.json'; import foodSafetyEs from './es/foodSafety.json'; import suppliersEs from './es/suppliers.json'; +import ordersEs from './es/orders.json'; import errorsEs from './es/errors.json'; // Translation resources by language @@ -14,6 +15,7 @@ export const resources = { inventory: inventoryEs, foodSafety: foodSafetyEs, suppliers: suppliersEs, + orders: ordersEs, errors: errorsEs, }, }; @@ -37,7 +39,7 @@ export const languageConfig = { }; // Namespaces available in translations -export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'errors'] as const; +export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'orders', 'errors'] as const; export type Namespace = typeof namespaces[number]; // Helper function to get language display name @@ -51,7 +53,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang }; // Export individual language modules for direct imports -export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, errorsEs }; +export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, ordersEs, errorsEs }; // Default export with all translations export default resources; \ No newline at end of file diff --git a/frontend/src/pages/app/operations/orders/OrdersPage.tsx b/frontend/src/pages/app/operations/orders/OrdersPage.tsx index c003e2ad..ef05bed3 100644 --- a/frontend/src/pages/app/operations/orders/OrdersPage.tsx +++ b/frontend/src/pages/app/operations/orders/OrdersPage.tsx @@ -1,195 +1,335 @@ import React, { useState } from 'react'; -import { Plus, Download, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react'; +import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react'; import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; -import { OrderForm } from '../../../../components/domain/sales'; +import { + OrderStatus, + OrderResponse, + CustomerResponse, + OrderCreate, + PaymentStatus, + DeliveryMethod, + PaymentMethod, + OrderType, + PriorityLevel, + OrderSource, + SalesChannel, + PaymentTerms, + CustomerType, + CustomerSegment +} from '../../../../api/types/orders'; +import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer } from '../../../../api/hooks/orders'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { OrderFormModal } from '../../../../components/domain/orders'; +import { useOrderEnums } from '../../../../utils/enumHelpers'; const OrdersPage: React.FC = () => { - const [activeTab] = useState('all'); + const [activeTab, setActiveTab] = useState<'orders' | 'customers'>('orders'); const [searchTerm, setSearchTerm] = useState(''); const [showForm, setShowForm] = useState(false); const [modalMode, setModalMode] = useState<'view' | 'edit'>('view'); - const [selectedOrder, setSelectedOrder] = useState(null); + const [selectedOrder, setSelectedOrder] = useState(null); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [showNewOrderForm, setShowNewOrderForm] = useState(false); + const [showNewCustomerForm, setShowNewCustomerForm] = useState(false); - const mockOrders = [ - { - id: 'ORD-2024-001', - customerName: 'María García', - customerEmail: 'maria@email.com', - customerPhone: '+34 600 123 456', - status: 'pending', - orderDate: '2024-01-26T09:30:00Z', - deliveryDate: '2024-01-26T16:00:00Z', - items: [ - { id: '1', name: 'Pan de Molde Integral', quantity: 2, price: 4.50, total: 9.00 }, - { id: '2', name: 'Croissants de Mantequilla', quantity: 6, price: 1.50, total: 9.00 }, - ], - subtotal: 18.00, - tax: 1.89, - discount: 0, - total: 19.89, - paymentMethod: 'card', - paymentStatus: 'pending', - deliveryMethod: 'pickup', - notes: 'Sin gluten por favor en el pan', - priority: 'normal', - }, - { - id: 'ORD-2024-002', - customerName: 'Juan Pérez', - customerEmail: 'juan@email.com', - customerPhone: '+34 600 654 321', - status: 'completed', - orderDate: '2024-01-25T14:15:00Z', - deliveryDate: '2024-01-25T18:30:00Z', - items: [ - { id: '3', name: 'Tarta de Chocolate', quantity: 1, price: 25.00, total: 25.00 }, - { id: '4', name: 'Magdalenas', quantity: 12, price: 0.75, total: 9.00 }, - ], - subtotal: 34.00, - tax: 3.57, - discount: 2.00, - total: 35.57, - paymentMethod: 'cash', - paymentStatus: 'paid', - deliveryMethod: 'delivery', - notes: 'Cumpleaños - decoración especial', - priority: 'high', - }, - { - id: 'ORD-2024-003', - customerName: 'Ana Martínez', - customerEmail: 'ana@email.com', - customerPhone: '+34 600 987 654', - status: 'in_progress', - orderDate: '2024-01-26T07:45:00Z', - deliveryDate: '2024-01-26T12:00:00Z', - items: [ - { id: '5', name: 'Baguettes Francesas', quantity: 4, price: 2.80, total: 11.20 }, - { id: '6', name: 'Empanadas', quantity: 8, price: 2.50, total: 20.00 }, - ], - subtotal: 31.20, - tax: 3.28, - discount: 0, - total: 34.48, - paymentMethod: 'transfer', - paymentStatus: 'paid', - deliveryMethod: 'pickup', - notes: '', - priority: 'normal', - }, - ]; + // Get tenant ID from tenant store (preferred) or auth user (fallback) + const currentTenant = useCurrentTenant(); + const user = useAuthUser(); + const tenantId = currentTenant?.id || user?.tenant_id || ''; + const orderEnums = useOrderEnums(); - const getOrderStatusConfig = (status: string, paymentStatus: string) => { + // API hooks for orders + const { + data: ordersData, + isLoading: ordersLoading, + error: ordersError + } = useOrders({ + tenant_id: tenantId, + status_filter: activeTab === 'orders' ? undefined : undefined, + limit: 100 + }); + + // API hooks for customers + const { + data: customersData, + isLoading: customersLoading, + error: customersError + } = useCustomers({ + tenant_id: tenantId, + active_only: true, + limit: 100 + }); + + // API hooks for dashboard data + const { + data: dashboardData, + isLoading: dashboardLoading, + error: dashboardError + } = useOrdersDashboard(tenantId); + + // Mutations + const createOrderMutation = useCreateOrder(); + const createCustomerMutation = useCreateCustomer(); + + const orders = ordersData || []; + const customers = customersData || []; + + const getOrderStatusConfig = (status: OrderStatus, paymentStatus?: PaymentStatus) => { const statusConfig = { - pending: { text: 'Pendiente', icon: Clock }, - in_progress: { text: 'En Proceso', icon: Timer }, - ready: { text: 'Listo', icon: CheckCircle }, - completed: { text: 'Completado', icon: CheckCircle }, - cancelled: { text: 'Cancelado', icon: AlertCircle }, + [OrderStatus.PENDING]: { text: 'Pendiente', icon: Clock }, + [OrderStatus.CONFIRMED]: { text: 'Confirmado', icon: CheckCircle }, + [OrderStatus.IN_PRODUCTION]: { text: 'En Producción', icon: Timer }, + [OrderStatus.READY]: { text: 'Listo', icon: CheckCircle }, + [OrderStatus.OUT_FOR_DELIVERY]: { text: 'En Reparto', icon: Timer }, + [OrderStatus.DELIVERED]: { text: 'Entregado', icon: CheckCircle }, + [OrderStatus.CANCELLED]: { text: 'Cancelado', icon: AlertCircle }, + [OrderStatus.FAILED]: { text: 'Fallido', icon: AlertCircle }, }; - - const config = statusConfig[status as keyof typeof statusConfig]; + + const config = statusConfig[status]; const Icon = config?.icon; - const isPaymentPending = paymentStatus === 'pending'; - + const isPaymentPending = paymentStatus === PaymentStatus.PENDING; + return { - color: getStatusColor(status), + color: getStatusColor( + status === OrderStatus.DELIVERED ? 'completed' : + status === OrderStatus.PENDING ? 'pending' : + status === OrderStatus.CANCELLED || status === OrderStatus.FAILED ? 'cancelled' : + 'in_progress' + ), text: config?.text || status, icon: Icon, - isCritical: false, + isCritical: status === OrderStatus.FAILED, isHighlight: isPaymentPending }; }; - const filteredOrders = mockOrders.filter(order => { - const matchesSearch = order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) || - order.id.toLowerCase().includes(searchTerm.toLowerCase()) || - order.customerEmail.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesTab = activeTab === 'all' || order.status === activeTab; - - return matchesSearch && matchesTab; - }); - - const mockOrderStats = { - total: mockOrders.length, - pending: mockOrders.filter(o => o.status === 'pending').length, - inProgress: mockOrders.filter(o => o.status === 'in_progress').length, - completed: mockOrders.filter(o => o.status === 'completed').length, - cancelled: mockOrders.filter(o => o.status === 'cancelled').length, - totalRevenue: mockOrders.reduce((sum, order) => sum + order.total, 0), - averageOrder: mockOrders.reduce((sum, order) => sum + order.total, 0) / mockOrders.length, - todayOrders: mockOrders.filter(o => - new Date(o.orderDate).toDateString() === new Date().toDateString() - ).length, + const getCustomerStatusConfig = (isActive: boolean) => { + return { + color: getStatusColor(isActive ? 'completed' : 'cancelled'), + text: isActive ? 'Activo' : 'Inactivo', + icon: isActive ? CheckCircle : AlertCircle, + isCritical: false, + isHighlight: false + }; }; - const stats = [ - { - title: 'Total Pedidos', - value: mockOrderStats.total, - variant: 'default' as const, - icon: Package, - }, - { - title: 'Pendientes', - value: mockOrderStats.pending, - variant: 'warning' as const, - icon: Clock, - }, - { - title: 'En Proceso', - value: mockOrderStats.inProgress, - variant: 'info' as const, - icon: Timer, - }, - { - title: 'Completados', - value: mockOrderStats.completed, - variant: 'success' as const, - icon: CheckCircle, - }, - { - title: 'Ingresos Total', - value: formatters.currency(mockOrderStats.totalRevenue), - variant: 'success' as const, - icon: Package, - }, - { - title: 'Promedio', - value: formatters.currency(mockOrderStats.averageOrder), - variant: 'info' as const, - icon: Package, - }, - ]; + const filteredOrders = orders.filter(order => { + return order.order_number.toLowerCase().includes(searchTerm.toLowerCase()) || + order.id.toLowerCase().includes(searchTerm.toLowerCase()); + }); + + const filteredCustomers = customers.filter(customer => { + return customer.name.toLowerCase().includes(searchTerm.toLowerCase()) || + customer.customer_code.toLowerCase().includes(searchTerm.toLowerCase()) || + (customer.email && customer.email.toLowerCase().includes(searchTerm.toLowerCase())); + }); + + const orderStats = dashboardData || { + total_orders_today: 0, + total_orders_this_week: 0, + total_orders_this_month: 0, + revenue_today: 0, + revenue_this_week: 0, + revenue_this_month: 0, + pending_orders: 0, + confirmed_orders: 0, + in_production_orders: 0, + ready_orders: 0, + delivered_orders: 0, + total_customers: 0, + new_customers_this_month: 0, + repeat_customers_rate: 0, + average_order_value: 0, + order_fulfillment_rate: 0, + on_time_delivery_rate: 0 + }; + + const getStatsForActiveTab = () => { + if (activeTab === 'orders') { + return [ + { + title: 'Pedidos Hoy', + value: orderStats.total_orders_today, + variant: 'default' as const, + icon: Package, + }, + { + title: 'Pendientes', + value: orderStats.pending_orders, + variant: 'warning' as const, + icon: Clock, + }, + { + title: 'En Producción', + value: orderStats.in_production_orders, + variant: 'info' as const, + icon: Timer, + }, + { + title: 'Listos', + value: orderStats.ready_orders, + variant: 'success' as const, + icon: CheckCircle, + }, + { + title: 'Ingresos Hoy', + value: formatters.currency(orderStats.revenue_today), + variant: 'success' as const, + icon: DollarSign, + }, + { + title: 'Valor Promedio', + value: formatters.currency(orderStats.average_order_value), + variant: 'info' as const, + icon: DollarSign, + }, + ]; + } else { + return [ + { + title: 'Total Clientes', + value: orderStats.total_customers, + variant: 'default' as const, + icon: Users, + }, + { + title: 'Nuevos Este Mes', + value: orderStats.new_customers_this_month, + variant: 'success' as const, + icon: Users, + }, + { + title: 'Tasa de Repetición', + value: `${(orderStats.repeat_customers_rate * 100).toFixed(1)}%`, + variant: 'info' as const, + icon: Users, + }, + { + title: 'Clientes Activos', + value: customers.filter(c => c.is_active).length, + variant: 'success' as const, + icon: CheckCircle, + }, + { + title: 'Valor Total', + value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.total_spent || 0), 0)), + variant: 'success' as const, + icon: DollarSign, + }, + { + title: 'Promedio por Cliente', + value: formatters.currency(customers.reduce((sum, c) => sum + Number(c.average_order_value || 0), 0) / Math.max(customers.length, 1)), + variant: 'info' as const, + icon: DollarSign, + }, + ]; + } + }; + + const stats = getStatsForActiveTab(); + + // Loading state + if (ordersLoading || customersLoading || dashboardLoading) { + return ( +
+
+ +

+ Cargando {activeTab === 'orders' ? 'pedidos' : 'clientes'}... +

+
+
+ ); + } + + // Error state + if (ordersError || customersError || dashboardError) { + return ( +
+
+ +

+ Error al cargar {activeTab === 'orders' ? 'los pedidos' : 'los clientes'} +

+

+ {(ordersError as any)?.message || (customersError as any)?.message || (dashboardError as any)?.message || 'Error desconocido'} +

+
+
+ ); + } return ( -
- + console.log('Export orders') - }, { id: "new", - label: "Nuevo Pedido", + label: activeTab === 'orders' ? 'Nuevo Pedido' : 'Nuevo Cliente', variant: "primary" as const, icon: Plus, - onClick: () => setShowForm(true) + onClick: () => { + if (activeTab === 'orders') { + setShowNewOrderForm(true); + } else { + setSelectedCustomer({ + name: '', + business_name: '', + customer_type: CustomerType.INDIVIDUAL, + email: '', + phone: '', + country: 'España', + is_active: true, + preferred_delivery_method: DeliveryMethod.PICKUP, + payment_terms: PaymentTerms.IMMEDIATE, + discount_percentage: 0, + customer_segment: CustomerSegment.REGULAR, + priority_level: PriorityLevel.NORMAL + } as any); + setIsCreating(true); + setModalMode('edit'); + setShowForm(true); + } + } } ]} /> + {/* Tabs - Mobile-friendly */} + +
+ + +
+
+ {/* Stats Grid */} - @@ -199,72 +339,131 @@ const OrdersPage: React.FC = () => {
setSearchTerm(e.target.value)} className="w-full" />
-
- {/* Orders Grid */} + {/* Content Grid - Mobile-first responsive */}
- {filteredOrders.map((order) => { - const statusConfig = getOrderStatusConfig(order.status, order.paymentStatus); - const paymentNote = order.paymentStatus === 'pending' ? 'Pago pendiente' : ''; - - return ( - { - setSelectedOrder(order); - setModalMode('view'); - setShowForm(true); + {activeTab === 'orders' ? ( + filteredOrders.map((order) => { + const statusConfig = getOrderStatusConfig(order.status, order.payment_status); + const paymentNote = order.payment_status === PaymentStatus.PENDING ? 'Pago pendiente' : ''; + + // Find customer data for better display + const customer = customers.find(c => c.id === order.customer_id); + const customerDisplayName = customer?.name || `Cliente ID: ${order.customer_id}`; + + return ( + { + setSelectedOrder(order); + setIsCreating(false); + setModalMode('view'); + setShowForm(true); + } + }, + { + label: 'Editar', + icon: Edit, + priority: 'secondary', + onClick: () => { + setSelectedOrder(order); + setIsCreating(false); + setModalMode('edit'); + setShowForm(true); + } } - }, - { - label: 'Editar', - icon: Edit, - variant: 'outline', - onClick: () => { - setSelectedOrder(order); - setModalMode('edit'); - setShowForm(true); + ]} + /> + ); + }) + ) : ( + filteredCustomers.map((customer) => { + const statusConfig = getCustomerStatusConfig(customer.is_active); + + return ( + { + setSelectedCustomer(customer); + setIsCreating(false); + setModalMode('view'); + setShowForm(true); + } + }, + { + label: 'Editar', + icon: Edit, + priority: 'secondary', + onClick: () => { + setSelectedCustomer(customer); + setIsCreating(false); + setModalMode('edit'); + setShowForm(true); + } } - } - ]} - /> - ); - })} + ]} + /> + ); + }) + )}
{/* Empty State */} - {filteredOrders.length === 0 && ( + {activeTab === 'orders' && filteredOrders.length === 0 && (

@@ -273,127 +472,374 @@ const OrdersPage: React.FC = () => {

Intenta ajustar la búsqueda o crear un nuevo pedido

-

)} - {/* Order Details Modal */} - {showForm && selectedOrder && ( - { - setShowForm(false); - setSelectedOrder(null); - setModalMode('view'); - }} - mode={modalMode} - onModeChange={setModalMode} - title={selectedOrder.customerName} - subtitle={`Pedido ${selectedOrder.id}`} - statusIndicator={getOrderStatusConfig(selectedOrder.status, selectedOrder.paymentStatus)} - size="lg" - sections={[ - { - title: 'Información del Cliente', - icon: Users, - fields: [ - { - label: 'Nombre', - value: selectedOrder.customerName, - highlight: true - }, - { - label: 'Email', - value: selectedOrder.customerEmail - }, - { - label: 'Teléfono', - value: selectedOrder.customerPhone - }, - { - label: 'Método de entrega', - value: selectedOrder.deliveryMethod === 'pickup' ? 'Recogida' : 'Entrega a domicilio' - } - ] - }, - { - title: 'Detalles del Pedido', - icon: Package, - fields: [ - { - label: 'Fecha del pedido', - value: selectedOrder.orderDate, - type: 'datetime' - }, - { - label: 'Fecha de entrega', - value: selectedOrder.deliveryDate, - type: 'datetime', - highlight: true - }, - { - label: 'Artículos', - value: selectedOrder.items?.map(item => `${item.name} (${item.quantity})`), - type: 'list', - span: 2 - } - ] - }, - { - title: 'Información Financiera', - icon: DollarSign, - fields: [ - { - label: 'Subtotal', - value: selectedOrder.subtotal, - type: 'currency' - }, - { - label: 'Impuestos', - value: selectedOrder.tax, - type: 'currency' - }, - { - label: 'Descuento', - value: selectedOrder.discount, - type: 'currency' - }, - { - label: 'Total', - value: selectedOrder.total, - type: 'currency', - highlight: true - }, - { - label: 'Método de pago', - value: selectedOrder.paymentMethod === 'card' ? 'Tarjeta' : selectedOrder.paymentMethod === 'cash' ? 'Efectivo' : 'Transferencia' - }, - { - label: 'Estado del pago', - value: selectedOrder.paymentStatus === 'paid' ? 'Pagado' : 'Pendiente', - type: 'status' - } - ] - }, - ...(selectedOrder.notes ? [{ - title: 'Notas', - fields: [ - { - label: 'Observaciones', - value: selectedOrder.notes, - span: 2 as const - } - ] - }] : []) - ]} - onEdit={() => { - console.log('Editing order:', selectedOrder.id); - }} - /> + {activeTab === 'customers' && filteredCustomers.length === 0 && ( +
+ +

+ No se encontraron clientes +

+

+ Intenta ajustar la búsqueda o crear un nuevo cliente +

+ +
)} + + {/* Order Details Modal */} + {showForm && selectedOrder && activeTab === 'orders' && (() => { + const sections = [ + { + title: 'Información del Pedido', + icon: Package, + fields: [ + { + label: 'Número de Pedido', + value: selectedOrder.order_number || '', + type: 'text', + highlight: true, + editable: false + }, + { + label: 'Tipo de Pedido', + value: selectedOrder.order_type || OrderType.STANDARD, + type: 'select', + editable: true, + options: orderEnums.getOrderTypeOptions() + }, + { + label: 'Prioridad', + value: selectedOrder.priority || PriorityLevel.NORMAL, + type: 'select', + editable: true, + options: orderEnums.getPriorityLevelOptions() + }, + { + label: 'Método de Entrega', + value: selectedOrder.delivery_method || DeliveryMethod.PICKUP, + type: 'select', + editable: true, + options: orderEnums.getDeliveryMethodOptions() + }, + { + label: 'Fecha del Pedido', + value: selectedOrder.order_date, + type: 'datetime', + editable: false + }, + { + label: 'Fecha de Entrega Solicitada', + value: selectedOrder.requested_delivery_date, + type: 'datetime', + editable: true, + highlight: true + } + ] + }, + { + title: 'Información Financiera', + icon: DollarSign, + fields: [ + { + label: 'Subtotal', + value: selectedOrder.subtotal, + type: 'currency', + editable: false + }, + { + label: 'Descuento', + value: selectedOrder.discount_amount, + type: 'currency', + editable: true + }, + { + label: 'Impuestos', + value: selectedOrder.tax_amount, + type: 'currency', + editable: false + }, + { + label: 'Gastos de Envío', + value: selectedOrder.delivery_fee, + type: 'currency', + editable: true + }, + { + label: 'Total', + value: selectedOrder.total_amount, + type: 'currency', + highlight: true, + editable: false + }, + { + label: 'Estado del Pago', + value: selectedOrder.payment_status || PaymentStatus.PENDING, + type: 'select', + editable: true, + options: orderEnums.getPaymentStatusOptions() + } + ] + }, + ...(selectedOrder.special_instructions ? [{ + title: 'Instrucciones Especiales', + fields: [ + { + label: 'Observaciones', + value: selectedOrder.special_instructions, + type: 'textarea', + span: 2 as const, + editable: true + } + ] + }] : []) + ]; + + return ( + { + setShowForm(false); + setSelectedOrder(null); + setModalMode('view'); + setIsCreating(false); + }} + mode={modalMode} + onModeChange={setModalMode} + title={isCreating ? 'Nuevo Pedido' : selectedOrder.order_number || 'Pedido'} + subtitle={isCreating ? 'Crear nuevo pedido' : `Cliente: ${selectedOrder.customer_id}`} + statusIndicator={isCreating ? undefined : getOrderStatusConfig(selectedOrder.status, selectedOrder.payment_status)} + size="lg" + sections={sections} + showDefaultActions={true} + onSave={async () => { + console.log('Saving order:', selectedOrder); + }} + onFieldChange={(sectionIndex, fieldIndex, value) => { + const newOrder = { ...selectedOrder }; + const section = sections[sectionIndex]; + const field = section.fields[fieldIndex]; + + const fieldMapping: { [key: string]: string } = { + 'Tipo de Pedido': 'order_type', + 'Prioridad': 'priority', + 'Método de Entrega': 'delivery_method', + 'Fecha de Entrega Solicitada': 'requested_delivery_date', + 'Descuento': 'discount_amount', + 'Gastos de Envío': 'delivery_fee', + 'Estado del Pago': 'payment_status', + 'Observaciones': 'special_instructions' + }; + + const propertyName = fieldMapping[field.label]; + if (propertyName) { + (newOrder as any)[propertyName] = value; + setSelectedOrder(newOrder); + } + }} + /> + ); + })()} + + {/* Customer Details Modal */} + {showForm && selectedCustomer && activeTab === 'customers' && (() => { + const sections = [ + { + title: 'Información de Contacto', + icon: Users, + fields: [ + { + label: 'Nombre', + value: selectedCustomer.name || '', + type: 'text', + highlight: true, + editable: true, + required: true + }, + { + label: 'Nombre Comercial', + value: selectedCustomer.business_name || '', + type: 'text', + editable: true + }, + { + label: 'Tipo de Cliente', + value: selectedCustomer.customer_type || CustomerType.INDIVIDUAL, + type: 'select', + editable: true, + options: orderEnums.getCustomerTypeOptions() + }, + { + label: 'Email', + value: selectedCustomer.email || '', + type: 'email', + editable: true + }, + { + label: 'Teléfono', + value: selectedCustomer.phone || '', + type: 'tel', + editable: true + }, + { + label: 'Ciudad', + value: selectedCustomer.city || '', + type: 'text', + editable: true + } + ] + }, + { + title: 'Configuración Comercial', + icon: DollarSign, + fields: [ + { + label: 'Código de Cliente', + value: selectedCustomer.customer_code || '', + type: 'text', + highlight: true, + editable: isCreating + }, + { + label: 'Método de Entrega Preferido', + value: selectedCustomer.preferred_delivery_method || DeliveryMethod.PICKUP, + type: 'select', + editable: true, + options: orderEnums.getDeliveryMethodOptions() + }, + { + label: 'Términos de Pago', + value: selectedCustomer.payment_terms || PaymentTerms.IMMEDIATE, + type: 'select', + editable: true, + options: orderEnums.getPaymentTermsOptions() + }, + { + label: 'Descuento (%)', + value: selectedCustomer.discount_percentage || 0, + type: 'number', + editable: true + }, + { + label: 'Segmento', + value: selectedCustomer.customer_segment || CustomerSegment.REGULAR, + type: 'select', + editable: true, + options: orderEnums.getCustomerSegmentOptions() + }, + { + label: 'Estado', + value: selectedCustomer.is_active, + type: 'boolean', + editable: true + } + ] + }, + ...(selectedCustomer.special_instructions ? [{ + title: 'Instrucciones Especiales', + fields: [ + { + label: 'Observaciones', + value: selectedCustomer.special_instructions, + type: 'textarea', + span: 2 as const, + editable: true + } + ] + }] : []) + ]; + + return ( + { + setShowForm(false); + setSelectedCustomer(null); + setModalMode('view'); + setIsCreating(false); + }} + mode={modalMode} + onModeChange={setModalMode} + title={isCreating ? 'Nuevo Cliente' : selectedCustomer.name || 'Cliente'} + subtitle={isCreating ? 'Crear nuevo cliente' : `Cliente ${selectedCustomer.customer_code || ''}`} + statusIndicator={isCreating ? undefined : getCustomerStatusConfig(selectedCustomer.is_active)} + size="lg" + sections={sections} + showDefaultActions={true} + onSave={async () => { + console.log('Saving customer:', selectedCustomer); + }} + onFieldChange={(sectionIndex, fieldIndex, value) => { + const newCustomer = { ...selectedCustomer }; + const section = sections[sectionIndex]; + const field = section.fields[fieldIndex]; + + const fieldMapping: { [key: string]: string } = { + 'Nombre': 'name', + 'Nombre Comercial': 'business_name', + 'Tipo de Cliente': 'customer_type', + 'Email': 'email', + 'Teléfono': 'phone', + 'Ciudad': 'city', + 'Código de Cliente': 'customer_code', + 'Método de Entrega Preferido': 'preferred_delivery_method', + 'Términos de Pago': 'payment_terms', + 'Descuento (%)': 'discount_percentage', + 'Segmento': 'customer_segment', + 'Estado': 'is_active', + 'Observaciones': 'special_instructions' + }; + + const propertyName = fieldMapping[field.label]; + if (propertyName) { + (newCustomer as any)[propertyName] = value; + setSelectedCustomer(newCustomer); + } + }} + /> + ); + })()} + + {/* New Order Form Modal */} + setShowNewOrderForm(false)} + onSave={async (orderData) => { + try { + await createOrderMutation.mutateAsync(orderData); + setShowNewOrderForm(false); + } catch (error) { + console.error('Error creating order:', error); + throw error; + } + }} + />
); }; diff --git a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx index 49d55e3e..b5cb3f07 100644 --- a/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx +++ b/frontend/src/pages/app/operations/procurement/ProcurementPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, Play, Pause, X, Save } from 'lucide-react'; +import { Plus, Search, Download, ShoppingCart, Truck, DollarSign, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, Play, Pause, X, Save, Building2 } from 'lucide-react'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { PageHeader } from '../../../../components/layout'; @@ -410,17 +410,22 @@ const ProcurementPage: React.FC = () => { id={plan.plan_number} statusIndicator={statusConfig} title={`Plan ${plan.plan_number}`} - subtitle={new Date(plan.plan_date).toLocaleDateString('es-ES')} - primaryValue={formatters.currency(plan.total_estimated_cost)} - primaryValueLabel={`${plan.total_requirements} requerimientos`} + subtitle={`${new Date(plan.plan_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })} • ${plan.procurement_strategy}`} + primaryValue={plan.total_requirements} + primaryValueLabel="requerimientos" secondaryInfo={{ - label: 'Período', - value: `${new Date(plan.plan_period_start).toLocaleDateString('es-ES')} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES')}` + label: 'Presupuesto', + value: `€${formatters.compact(plan.total_estimated_cost)}` }} + progress={plan.planning_horizon_days ? { + label: `${plan.planning_horizon_days} días de horizonte`, + percentage: Math.min((plan.planning_horizon_days / 30) * 100, 100), + color: plan.planning_horizon_days > 14 ? '#10b981' : plan.planning_horizon_days > 7 ? '#f59e0b' : '#ef4444' + } : undefined} metadata={[ - `${plan.planning_horizon_days} días de horizonte`, - `Estrategia: ${plan.procurement_strategy}`, - ...(plan.special_requirements ? [`"${plan.special_requirements}"`] : []) + `Período: ${new Date(plan.plan_period_start).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`, + `Creado: ${new Date(plan.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`, + ...(plan.special_requirements ? [`Req. especiales: ${plan.special_requirements}`] : []) ]} actions={actions} /> @@ -541,24 +546,43 @@ const ProcurementPage: React.FC = () => { isCritical: true }} title={requirement.product_name} - subtitle={requirement.requirement_number} - primaryValue={`${requirement.required_quantity} ${requirement.unit_of_measure}`} - primaryValueLabel="Cantidad requerida" + subtitle={`${requirement.requirement_number} • ${requirement.supplier_name || 'Sin proveedor'}`} + primaryValue={requirement.required_quantity} + primaryValueLabel={requirement.unit_of_measure} secondaryInfo={{ - label: 'Fecha límite', - value: new Date(requirement.required_by_date).toLocaleDateString('es-ES') + label: 'Límite', + value: new Date(requirement.required_by_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' }) }} + progress={requirement.current_stock_level && requirement.required_quantity ? { + label: `${Math.round((requirement.current_stock_level / requirement.required_quantity) * 100)}% cubierto`, + percentage: Math.min((requirement.current_stock_level / requirement.required_quantity) * 100, 100), + color: requirement.current_stock_level >= requirement.required_quantity ? '#10b981' : requirement.current_stock_level >= requirement.required_quantity * 0.5 ? '#f59e0b' : '#ef4444' + } : undefined} metadata={[ - `Stock actual: ${requirement.current_stock_level} ${requirement.unit_of_measure}`, - `Proveedor: ${requirement.supplier_name || 'No asignado'}`, - `Costo estimado: ${formatters.currency(requirement.estimated_total_cost || 0)}` + `Stock: ${requirement.current_stock_level || 0} ${requirement.unit_of_measure}`, + `Necesario: ${requirement.required_quantity - (requirement.current_stock_level || 0)} ${requirement.unit_of_measure}`, + `Costo: €${formatters.compact(requirement.estimated_total_cost || 0)}`, + `Días restantes: ${Math.ceil((new Date(requirement.required_by_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24))}` ]} actions={[ { label: 'Ver Detalles', icon: Eye, - variant: 'outline', + variant: 'primary', + priority: 'primary', onClick: () => console.log('View requirement details') + }, + { + label: 'Asignar Proveedor', + icon: Building2, + priority: 'secondary', + onClick: () => console.log('Assign supplier') + }, + { + label: 'Comprar Ahora', + icon: ShoppingCart, + priority: 'secondary', + onClick: () => console.log('Purchase now') } ]} /> diff --git a/frontend/src/pages/app/operations/recipes/RecipesPage.tsx b/frontend/src/pages/app/operations/recipes/RecipesPage.tsx index c405e1d1..4ed63340 100644 --- a/frontend/src/pages/app/operations/recipes/RecipesPage.tsx +++ b/frontend/src/pages/app/operations/recipes/RecipesPage.tsx @@ -254,11 +254,11 @@ const RecipesPage: React.FC = () => { statusIndicator={statusConfig} title={recipe.name} subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`} - primaryValue={formatters.currency(recipe.profit)} - primaryValueLabel="margen" + primaryValue={recipe.ingredients.length} + primaryValueLabel="ingredientes" secondaryInfo={{ - label: 'Precio de venta', - value: `${formatters.currency(recipe.price)} (costo: ${formatters.currency(recipe.cost)})` + label: 'Margen', + value: `€${formatters.compact(recipe.profit)}` }} progress={{ label: 'Margen de beneficio', diff --git a/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx index 81834203..c546ed33 100644 --- a/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx +++ b/frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx @@ -216,12 +216,12 @@ const SuppliersPage: React.FC = () => { id={supplier.id} statusIndicator={statusConfig} title={supplier.name} - subtitle={supplier.supplier_code} - primaryValue={supplier.city || 'Sin ubicación'} - primaryValueLabel={getSupplierTypeText(supplier.supplier_type)} + subtitle={`${getSupplierTypeText(supplier.supplier_type)} • ${supplier.city || 'Sin ubicación'}`} + primaryValue={supplier.standard_lead_time || 0} + primaryValueLabel="días" secondaryInfo={{ - label: 'Condiciones', - value: getPaymentTermsText(supplier.payment_terms) + label: 'Pedido Min.', + value: `€${formatters.compact(supplier.minimum_order_amount || 0)}` }} metadata={[ supplier.contact_person || 'Sin contacto', diff --git a/frontend/src/pages/app/settings/team/TeamPage.tsx b/frontend/src/pages/app/settings/team/TeamPage.tsx index b1c8b66c..a1651580 100644 --- a/frontend/src/pages/app/settings/team/TeamPage.tsx +++ b/frontend/src/pages/app/settings/team/TeamPage.tsx @@ -377,11 +377,11 @@ const TeamPage: React.FC = () => { statusIndicator={getMemberStatusConfig(member)} title={member.user?.full_name || member.user_full_name} subtitle={member.user?.email || member.user_email} - primaryValue={member.is_active ? 'Activo' : 'Inactivo'} - primaryValueLabel="Estado" + primaryValue={Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24))} + primaryValueLabel="días" secondaryInfo={{ - label: 'Se unió', - value: new Date(member.joined_at).toLocaleDateString('es-ES') + label: 'Estado', + value: member.is_active ? 'Activo' : 'Inactivo' }} metadata={[ `Email: ${member.user?.email || member.user_email}`, diff --git a/frontend/src/stores/auth.store.ts b/frontend/src/stores/auth.store.ts index ebdb41c0..ce9bf413 100644 --- a/frontend/src/stores/auth.store.ts +++ b/frontend/src/stores/auth.store.ts @@ -256,16 +256,15 @@ export const useAuthStore = create()( onRehydrateStorage: () => (state) => { // Initialize API client with stored tokens when store rehydrates if (state?.token) { - import('../api').then(({ apiClient }) => { - apiClient.setAuthToken(state.token!); - if (state.refreshToken) { - apiClient.setRefreshToken(state.refreshToken); - } + // Use direct import to avoid timing issues + apiClient.setAuthToken(state.token); + if (state.refreshToken) { + apiClient.setRefreshToken(state.refreshToken); + } - if (state.user?.tenant_id) { - apiClient.setTenantId(state.user.tenant_id); - } - }); + if (state.user?.tenant_id) { + apiClient.setTenantId(state.user.tenant_id); + } } }, } diff --git a/frontend/src/utils/enumHelpers.ts b/frontend/src/utils/enumHelpers.ts index 03f1b0fd..03ff1e66 100644 --- a/frontend/src/utils/enumHelpers.ts +++ b/frontend/src/utils/enumHelpers.ts @@ -17,6 +17,20 @@ import { type EnumOption } from '../api/types/suppliers'; +import { + CustomerType, + DeliveryMethod, + PaymentTerms as OrderPaymentTerms, + PaymentMethod, + PaymentStatus, + CustomerSegment, + PriorityLevel, + OrderType, + OrderStatus, + OrderSource, + SalesChannel +} from '../api/types/orders'; + /** * Generic function to convert enum to select options with i18n translations */ @@ -163,4 +177,132 @@ export function isValidEnumValue( value: unknown ): value is T { return Object.values(enumObject).includes(value as T); +} + +/** + * Hook for orders enum utilities + */ +export function useOrderEnums() { + const { t } = useTranslation('orders'); + + return { + // Customer Type + getCustomerTypeOptions: (): SelectOption[] => + enumToSelectOptions(CustomerType, 'customer_types', t), + + getCustomerTypeLabel: (type: CustomerType): string => { + if (!type) return 'Tipo no definido'; + const translated = t(`customer_types.${type}`); + if (translated === `customer_types.${type}`) { + return type.charAt(0).toUpperCase() + type.slice(1); + } + return translated; + }, + + // Delivery Method + getDeliveryMethodOptions: (): SelectOption[] => + enumToSelectOptions(DeliveryMethod, 'delivery_methods', t), + + getDeliveryMethodLabel: (method: DeliveryMethod): string => { + if (!method) return 'Método no definido'; + const translated = t(`delivery_methods.${method}`); + if (translated === `delivery_methods.${method}`) { + return method.charAt(0).toUpperCase() + method.slice(1); + } + return translated; + }, + + // Payment Terms + getPaymentTermsOptions: (): SelectOption[] => + enumToSelectOptions(OrderPaymentTerms, 'payment_terms', t), + + getPaymentTermsLabel: (terms: OrderPaymentTerms): string => { + if (!terms) return 'Términos no definidos'; + return t(`payment_terms.${terms}`); + }, + + // Payment Method + getPaymentMethodOptions: (): SelectOption[] => + enumToSelectOptions(PaymentMethod, 'payment_methods', t), + + getPaymentMethodLabel: (method: PaymentMethod): string => { + if (!method) return 'Método no definido'; + return t(`payment_methods.${method}`); + }, + + // Payment Status + getPaymentStatusOptions: (): SelectOption[] => + enumToSelectOptions(PaymentStatus, 'payment_status', t), + + getPaymentStatusLabel: (status: PaymentStatus): string => { + if (!status) return 'Estado no definido'; + return t(`payment_status.${status}`); + }, + + // Customer Segment + getCustomerSegmentOptions: (): SelectOption[] => + enumToSelectOptions(CustomerSegment, 'customer_segments', t), + + getCustomerSegmentLabel: (segment: CustomerSegment): string => { + if (!segment) return 'Segmento no definido'; + return t(`customer_segments.${segment}`); + }, + + // Priority Level + getPriorityLevelOptions: (): SelectOption[] => + enumToSelectOptions(PriorityLevel, 'priority_levels', t), + + getPriorityLevelLabel: (level: PriorityLevel): string => { + if (!level) return 'Prioridad no definida'; + return t(`priority_levels.${level}`); + }, + + // Order Type + getOrderTypeOptions: (): SelectOption[] => + enumToSelectOptions(OrderType, 'order_types', t), + + getOrderTypeLabel: (type: OrderType): string => { + if (!type) return 'Tipo no definido'; + const translated = t(`order_types.${type}`); + // If translation failed, return a fallback + if (translated === `order_types.${type}`) { + return type.charAt(0).toUpperCase() + type.slice(1); + } + return translated; + }, + + // Order Status + getOrderStatusOptions: (): SelectOption[] => + enumToSelectOptions(OrderStatus, 'order_status', t), + + getOrderStatusLabel: (status: OrderStatus): string => { + if (!status) return 'Estado no definido'; + return t(`order_status.${status}`); + }, + + // Order Source + getOrderSourceOptions: (): SelectOption[] => + enumToSelectOptions(OrderSource, 'order_sources', t), + + getOrderSourceLabel: (source: OrderSource): string => { + if (!source) return 'Origen no definido'; + return t(`order_sources.${source}`); + }, + + // Sales Channel + getSalesChannelOptions: (): SelectOption[] => + enumToSelectOptions(SalesChannel, 'sales_channels', t), + + getSalesChannelLabel: (channel: SalesChannel): string => { + if (!channel) return 'Canal no definido'; + return t(`sales_channels.${channel}`); + }, + + // Field Labels + getFieldLabel: (field: string): string => + t(`labels.${field}`), + + getFieldDescription: (field: string): string => + t(`descriptions.${field}`) + }; } \ No newline at end of file diff --git a/scripts/seed_orders_test_data.sh b/scripts/seed_orders_test_data.sh new file mode 100755 index 00000000..f6058a46 --- /dev/null +++ b/scripts/seed_orders_test_data.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Script to seed the orders database with test data +set -e + +echo "🌱 Seeding Orders Database with Test Data" +echo "=========================================" + +# Change to the orders service directory +cd services/orders + +# Make sure we're in a virtual environment or have the dependencies +echo "📦 Setting up environment..." + +# Run the seeding script +echo "🚀 Running seeding script..." +python scripts/seed_test_data.py + +echo "✅ Database seeding completed!" +echo "" +echo "🎯 Test data created:" +echo " - 6 customers (including VIP, wholesale, and inactive)" +echo " - 25 orders in various statuses" +echo " - Order items with different products" +echo " - Order status history" +echo "" +echo "📋 You can now test the frontend with real data!" \ No newline at end of file diff --git a/services/orders/app/api/orders.py b/services/orders/app/api/orders.py index 49b1cdae..13c11cbc 100644 --- a/services/orders/app/api/orders.py +++ b/services/orders/app/api/orders.py @@ -55,8 +55,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService: status_history_repo=OrderStatusHistoryRepository(), inventory_client=get_inventory_client(), production_client=get_production_client(), - sales_client=get_sales_client(), - notification_client=None # Notification client not available + sales_client=get_sales_client() ) diff --git a/services/orders/app/models/enums.py b/services/orders/app/models/enums.py new file mode 100644 index 00000000..76efd4f6 --- /dev/null +++ b/services/orders/app/models/enums.py @@ -0,0 +1,152 @@ +# services/orders/app/models/enums.py +""" +Enum definitions for Orders Service +Following the pattern used in the Inventory Service for better type safety and maintainability +""" + +import enum + + +class CustomerType(enum.Enum): + """Customer type classifications""" + INDIVIDUAL = "individual" + BUSINESS = "business" + CENTRAL_BAKERY = "central_bakery" + + +class DeliveryMethod(enum.Enum): + """Order delivery methods""" + DELIVERY = "delivery" + PICKUP = "pickup" + + +class PaymentTerms(enum.Enum): + """Payment terms for customers and orders""" + IMMEDIATE = "immediate" + NET_30 = "net_30" + NET_60 = "net_60" + + +class PaymentMethod(enum.Enum): + """Payment methods for orders""" + CASH = "cash" + CARD = "card" + BANK_TRANSFER = "bank_transfer" + ACCOUNT = "account" + + +class PaymentStatus(enum.Enum): + """Payment status for orders""" + PENDING = "pending" + PARTIAL = "partial" + PAID = "paid" + FAILED = "failed" + REFUNDED = "refunded" + + +class CustomerSegment(enum.Enum): + """Customer segmentation categories""" + VIP = "vip" + REGULAR = "regular" + WHOLESALE = "wholesale" + + +class PriorityLevel(enum.Enum): + """Priority levels for orders and customers""" + HIGH = "high" + NORMAL = "normal" + LOW = "low" + + +class OrderType(enum.Enum): + """Order type classifications""" + STANDARD = "standard" + RUSH = "rush" + RECURRING = "recurring" + SPECIAL = "special" + + +class OrderStatus(enum.Enum): + """Order status workflow""" + PENDING = "pending" + CONFIRMED = "confirmed" + IN_PRODUCTION = "in_production" + READY = "ready" + OUT_FOR_DELIVERY = "out_for_delivery" + DELIVERED = "delivered" + CANCELLED = "cancelled" + FAILED = "failed" + + +class OrderSource(enum.Enum): + """Source of order creation""" + MANUAL = "manual" + ONLINE = "online" + PHONE = "phone" + APP = "app" + API = "api" + + +class SalesChannel(enum.Enum): + """Sales channel classification""" + DIRECT = "direct" + WHOLESALE = "wholesale" + RETAIL = "retail" + + +class BusinessModel(enum.Enum): + """Business model types""" + INDIVIDUAL_BAKERY = "individual_bakery" + CENTRAL_BAKERY = "central_bakery" + + +# Procurement-related enums +class ProcurementPlanType(enum.Enum): + """Procurement plan types""" + REGULAR = "regular" + EMERGENCY = "emergency" + SEASONAL = "seasonal" + + +class ProcurementStrategy(enum.Enum): + """Procurement strategies""" + JUST_IN_TIME = "just_in_time" + BULK = "bulk" + MIXED = "mixed" + + +class RiskLevel(enum.Enum): + """Risk level classifications""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class RequirementStatus(enum.Enum): + """Procurement requirement status""" + PENDING = "pending" + APPROVED = "approved" + ORDERED = "ordered" + PARTIALLY_RECEIVED = "partially_received" + RECEIVED = "received" + CANCELLED = "cancelled" + + +class PlanStatus(enum.Enum): + """Procurement plan status""" + DRAFT = "draft" + PENDING_APPROVAL = "pending_approval" + APPROVED = "approved" + IN_EXECUTION = "in_execution" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class DeliveryStatus(enum.Enum): + """Delivery status for procurement""" + PENDING = "pending" + IN_TRANSIT = "in_transit" + DELIVERED = "delivered" + DELAYED = "delayed" + CANCELLED = "cancelled" \ No newline at end of file diff --git a/services/orders/app/repositories/order_repository.py b/services/orders/app/repositories/order_repository.py index 3bddff90..0d2835fb 100644 --- a/services/orders/app/repositories/order_repository.py +++ b/services/orders/app/repositories/order_repository.py @@ -5,7 +5,7 @@ Order-related repositories for Orders Service """ -from datetime import datetime, date +from datetime import datetime, date, timedelta from decimal import Decimal from typing import List, Optional, Dict, Any from uuid import UUID @@ -98,12 +98,86 @@ class CustomerRepository(BaseRepository[Customer, dict, dict]): error=str(e)) raise + async def count_created_since( + self, + db: AsyncSession, + tenant_id: UUID, + since_date: datetime + ) -> int: + """Count customers created since a specific date""" + try: + query = select(func.count()).select_from(Customer).where( + and_( + Customer.tenant_id == tenant_id, + Customer.created_at >= since_date + ) + ) + result = await db.execute(query) + return result.scalar() + except Exception as e: + logger.error("Error counting customers created since date", + tenant_id=str(tenant_id), + since_date=str(since_date), + error=str(e)) + raise + class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]): """Repository for customer order operations""" - + def __init__(self): super().__init__(CustomerOrder) + + async def get_multi( + self, + db: AsyncSession, + tenant_id: Optional[UUID] = None, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + order_by: Optional[str] = None, + order_desc: bool = False + ) -> List[CustomerOrder]: + """Get multiple orders with eager loading of items and customer""" + try: + query = select(self.model).options( + selectinload(CustomerOrder.items), + selectinload(CustomerOrder.customer) + ) + + # Apply tenant filter + if tenant_id: + query = query.where(self.model.tenant_id == tenant_id) + + # Apply additional filters + if filters: + for key, value in filters.items(): + if hasattr(self.model, key) and value is not None: + field = getattr(self.model, key) + if isinstance(value, list): + query = query.where(field.in_(value)) + else: + query = query.where(field == value) + + # Apply ordering + if order_by and hasattr(self.model, order_by): + order_column = getattr(self.model, order_by) + if order_desc: + query = query.order_by(order_column.desc()) + else: + query = query.order_by(order_column) + else: + # Default ordering by order_date desc + query = query.order_by(CustomerOrder.order_date.desc()) + + # Apply pagination + query = query.offset(skip).limit(limit) + + result = await db.execute(query) + return result.scalars().all() + except Exception as e: + logger.error("Error getting multiple orders", error=str(e)) + raise async def get_with_items( self, diff --git a/services/orders/app/schemas/order_schemas.py b/services/orders/app/schemas/order_schemas.py index a267f466..9487886f 100644 --- a/services/orders/app/schemas/order_schemas.py +++ b/services/orders/app/schemas/order_schemas.py @@ -11,13 +11,20 @@ from typing import Optional, List, Dict, Any from uuid import UUID from pydantic import BaseModel, Field, validator +from app.models.enums import ( + CustomerType, DeliveryMethod, PaymentTerms, PaymentMethod, PaymentStatus, + CustomerSegment, PriorityLevel, OrderType, OrderStatus, OrderSource, + SalesChannel, BusinessModel, ProcurementPlanType, ProcurementStrategy, + RiskLevel, RequirementStatus, PlanStatus, DeliveryStatus +) + # ===== Customer Schemas ===== class CustomerBase(BaseModel): name: str = Field(..., min_length=1, max_length=200) business_name: Optional[str] = Field(None, max_length=200) - customer_type: str = Field(default="individual", pattern="^(individual|business|central_bakery)$") + customer_type: CustomerType = Field(default=CustomerType.INDIVIDUAL) email: Optional[str] = Field(None, max_length=255) phone: Optional[str] = Field(None, max_length=50) address_line1: Optional[str] = Field(None, max_length=255) @@ -27,16 +34,20 @@ class CustomerBase(BaseModel): postal_code: Optional[str] = Field(None, max_length=20) country: str = Field(default="US", max_length=100) is_active: bool = Field(default=True) - preferred_delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$") - payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$") + preferred_delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY) + payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE) credit_limit: Optional[Decimal] = Field(None, ge=0) discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) - customer_segment: str = Field(default="regular", pattern="^(vip|regular|wholesale)$") - priority_level: str = Field(default="normal", pattern="^(high|normal|low)$") + customer_segment: CustomerSegment = Field(default=CustomerSegment.REGULAR) + priority_level: PriorityLevel = Field(default=PriorityLevel.NORMAL) special_instructions: Optional[str] = None delivery_preferences: Optional[Dict[str, Any]] = None product_preferences: Optional[Dict[str, Any]] = None + class Config: + from_attributes = True + use_enum_values = True + class CustomerCreate(CustomerBase): customer_code: str = Field(..., min_length=1, max_length=50) @@ -46,7 +57,7 @@ class CustomerCreate(CustomerBase): class CustomerUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=200) business_name: Optional[str] = Field(None, max_length=200) - customer_type: Optional[str] = Field(None, pattern="^(individual|business|central_bakery)$") + customer_type: Optional[CustomerType] = None email: Optional[str] = Field(None, max_length=255) phone: Optional[str] = Field(None, max_length=50) address_line1: Optional[str] = Field(None, max_length=255) @@ -56,16 +67,20 @@ class CustomerUpdate(BaseModel): postal_code: Optional[str] = Field(None, max_length=20) country: Optional[str] = Field(None, max_length=100) is_active: Optional[bool] = None - preferred_delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$") - payment_terms: Optional[str] = Field(None, pattern="^(immediate|net_30|net_60)$") + preferred_delivery_method: Optional[DeliveryMethod] = None + payment_terms: Optional[PaymentTerms] = None credit_limit: Optional[Decimal] = Field(None, ge=0) discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100) - customer_segment: Optional[str] = Field(None, pattern="^(vip|regular|wholesale)$") - priority_level: Optional[str] = Field(None, pattern="^(high|normal|low)$") + customer_segment: Optional[CustomerSegment] = None + priority_level: Optional[PriorityLevel] = None special_instructions: Optional[str] = None delivery_preferences: Optional[Dict[str, Any]] = None product_preferences: Optional[Dict[str, Any]] = None + class Config: + from_attributes = True + use_enum_values = True + class CustomerResponse(CustomerBase): id: UUID @@ -129,26 +144,30 @@ class OrderItemResponse(OrderItemBase): class OrderBase(BaseModel): customer_id: UUID - order_type: str = Field(default="standard", pattern="^(standard|rush|recurring|special)$") - priority: str = Field(default="normal", pattern="^(high|normal|low)$") + order_type: OrderType = Field(default=OrderType.STANDARD) + priority: PriorityLevel = Field(default=PriorityLevel.NORMAL) requested_delivery_date: datetime - delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$") + delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY) delivery_address: Optional[Dict[str, Any]] = None delivery_instructions: Optional[str] = None delivery_window_start: Optional[datetime] = None delivery_window_end: Optional[datetime] = None discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100) delivery_fee: Decimal = Field(default=Decimal("0.00"), ge=0) - payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$") - payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$") + payment_method: Optional[PaymentMethod] = None + payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE) special_instructions: Optional[str] = None custom_requirements: Optional[Dict[str, Any]] = None allergen_warnings: Optional[Dict[str, Any]] = None - order_source: str = Field(default="manual", pattern="^(manual|online|phone|app|api)$") - sales_channel: str = Field(default="direct", pattern="^(direct|wholesale|retail)$") + order_source: OrderSource = Field(default=OrderSource.MANUAL) + sales_channel: SalesChannel = Field(default=SalesChannel.DIRECT) order_origin: Optional[str] = Field(None, max_length=100) communication_preferences: Optional[Dict[str, Any]] = None + class Config: + from_attributes = True + use_enum_values = True + class OrderCreate(OrderBase): tenant_id: UUID @@ -156,21 +175,25 @@ class OrderCreate(OrderBase): class OrderUpdate(BaseModel): - status: Optional[str] = Field(None, pattern="^(pending|confirmed|in_production|ready|out_for_delivery|delivered|cancelled|failed)$") - priority: Optional[str] = Field(None, pattern="^(high|normal|low)$") + status: Optional[OrderStatus] = None + priority: Optional[PriorityLevel] = None requested_delivery_date: Optional[datetime] = None confirmed_delivery_date: Optional[datetime] = None - delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$") + delivery_method: Optional[DeliveryMethod] = None delivery_address: Optional[Dict[str, Any]] = None delivery_instructions: Optional[str] = None delivery_window_start: Optional[datetime] = None delivery_window_end: Optional[datetime] = None - payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$") - payment_status: Optional[str] = Field(None, pattern="^(pending|partial|paid|failed|refunded)$") + payment_method: Optional[PaymentMethod] = None + payment_status: Optional[PaymentStatus] = None special_instructions: Optional[str] = None custom_requirements: Optional[Dict[str, Any]] = None allergen_warnings: Optional[Dict[str, Any]] = None + class Config: + from_attributes = True + use_enum_values = True + class OrderResponse(OrderBase): id: UUID @@ -205,17 +228,21 @@ class ProcurementRequirementBase(BaseModel): product_name: str = Field(..., min_length=1, max_length=200) product_sku: Optional[str] = Field(None, max_length=100) product_category: Optional[str] = Field(None, max_length=100) - product_type: str = Field(default="ingredient", pattern="^(ingredient|packaging|supplies)$") + product_type: str = Field(default="ingredient") # TODO: Create ProductType enum if needed required_quantity: Decimal = Field(..., gt=0) unit_of_measure: str = Field(..., min_length=1, max_length=50) safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0) required_by_date: date - priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$") + priority: PriorityLevel = Field(default=PriorityLevel.NORMAL) preferred_supplier_id: Optional[UUID] = None quality_specifications: Optional[Dict[str, Any]] = None special_requirements: Optional[str] = None storage_requirements: Optional[str] = Field(None, max_length=200) + class Config: + from_attributes = True + use_enum_values = True + class ProcurementRequirementCreate(ProcurementRequirementBase): pass @@ -248,13 +275,17 @@ class ProcurementPlanBase(BaseModel): plan_period_start: date plan_period_end: date planning_horizon_days: int = Field(default=14, ge=1, le=365) - plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$") - priority: str = Field(default="normal", pattern="^(high|normal|low)$") - business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$") - procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$") + plan_type: ProcurementPlanType = Field(default=ProcurementPlanType.REGULAR) + priority: PriorityLevel = Field(default=PriorityLevel.NORMAL) + business_model: Optional[BusinessModel] = None + procurement_strategy: ProcurementStrategy = Field(default=ProcurementStrategy.JUST_IN_TIME) safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100) special_requirements: Optional[str] = None + class Config: + from_attributes = True + use_enum_values = True + class ProcurementPlanCreate(ProcurementPlanBase): tenant_id: UUID diff --git a/services/orders/app/services/orders_service.py b/services/orders/app/services/orders_service.py index a4a514d8..1d94c274 100644 --- a/services/orders/app/services/orders_service.py +++ b/services/orders/app/services/orders_service.py @@ -336,10 +336,10 @@ class OrdersService: # Get new customers this month month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) - new_customers_this_month = await self.customer_repo.count( - db, - tenant_id, - filters={"created_at": {"gte": month_start}} + new_customers_this_month = await self.customer_repo.count_created_since( + db, + tenant_id, + month_start ) # Get recent orders diff --git a/services/orders/scripts/seed_test_data.py b/services/orders/scripts/seed_test_data.py new file mode 100644 index 00000000..7ff9ff10 --- /dev/null +++ b/services/orders/scripts/seed_test_data.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Script to populate the database with test data for orders and customers +""" + +import os +import sys +import uuid +from datetime import datetime, timedelta +from decimal import Decimal +import asyncio +import random + +# Add the parent directory to the path to import our modules +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_session +from app.models.customer import Customer, CustomerContact +from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory + +# Test tenant ID - in a real environment this would be provided +TEST_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5" + +# Sample customer data +SAMPLE_CUSTOMERS = [ + { + "name": "María García López", + "customer_type": "individual", + "email": "maria.garcia@email.com", + "phone": "+34 612 345 678", + "city": "Madrid", + "country": "España", + "customer_segment": "vip", + "is_active": True + }, + { + "name": "Panadería San Juan", + "business_name": "Panadería San Juan S.L.", + "customer_type": "business", + "email": "pedidos@panaderiasjuan.com", + "phone": "+34 687 654 321", + "city": "Barcelona", + "country": "España", + "customer_segment": "wholesale", + "is_active": True + }, + { + "name": "Carlos Rodríguez Martín", + "customer_type": "individual", + "email": "carlos.rodriguez@email.com", + "phone": "+34 698 765 432", + "city": "Valencia", + "country": "España", + "customer_segment": "regular", + "is_active": True + }, + { + "name": "Ana Fernández Ruiz", + "customer_type": "individual", + "email": "ana.fernandez@email.com", + "phone": "+34 634 567 890", + "city": "Sevilla", + "country": "España", + "customer_segment": "regular", + "is_active": True + }, + { + "name": "Café Central", + "business_name": "Café Central Madrid S.L.", + "customer_type": "business", + "email": "compras@cafecentral.es", + "phone": "+34 623 456 789", + "city": "Madrid", + "country": "España", + "customer_segment": "wholesale", + "is_active": True + }, + { + "name": "Laura Martínez Silva", + "customer_type": "individual", + "email": "laura.martinez@email.com", + "phone": "+34 645 789 012", + "city": "Bilbao", + "country": "España", + "customer_segment": "regular", + "is_active": False # Inactive customer for testing + } +] + +# Sample products (in a real system these would come from a products service) +SAMPLE_PRODUCTS = [ + {"id": str(uuid.uuid4()), "name": "Pan Integral Artesano", "price": Decimal("2.50"), "category": "Panadería"}, + {"id": str(uuid.uuid4()), "name": "Croissant de Mantequilla", "price": Decimal("1.80"), "category": "Bollería"}, + {"id": str(uuid.uuid4()), "name": "Tarta de Santiago", "price": Decimal("18.90"), "category": "Repostería"}, + {"id": str(uuid.uuid4()), "name": "Magdalenas de Limón", "price": Decimal("0.90"), "category": "Bollería"}, + {"id": str(uuid.uuid4()), "name": "Empanada de Atún", "price": Decimal("3.50"), "category": "Salado"}, + {"id": str(uuid.uuid4()), "name": "Brownie de Chocolate", "price": Decimal("3.20"), "category": "Repostería"}, + {"id": str(uuid.uuid4()), "name": "Baguette Francesa", "price": Decimal("2.80"), "category": "Panadería"}, + {"id": str(uuid.uuid4()), "name": "Palmera de Chocolate", "price": Decimal("2.40"), "category": "Bollería"}, +] + +async def create_customers(session: AsyncSession) -> list[Customer]: + """Create sample customers""" + customers = [] + + for i, customer_data in enumerate(SAMPLE_CUSTOMERS): + customer = Customer( + tenant_id=TEST_TENANT_ID, + customer_code=f"CUST-{i+1:04d}", + name=customer_data["name"], + business_name=customer_data.get("business_name"), + customer_type=customer_data["customer_type"], + email=customer_data["email"], + phone=customer_data["phone"], + city=customer_data["city"], + country=customer_data["country"], + is_active=customer_data["is_active"], + preferred_delivery_method="delivery" if random.choice([True, False]) else "pickup", + payment_terms=random.choice(["immediate", "net_30"]), + customer_segment=customer_data["customer_segment"], + priority_level=random.choice(["normal", "high"]) if customer_data["customer_segment"] == "vip" else "normal", + discount_percentage=Decimal("5.0") if customer_data["customer_segment"] == "vip" else + Decimal("10.0") if customer_data["customer_segment"] == "wholesale" else Decimal("0.0"), + total_orders=random.randint(5, 50), + total_spent=Decimal(str(random.randint(100, 5000))), + average_order_value=Decimal(str(random.randint(15, 150))), + last_order_date=datetime.now() - timedelta(days=random.randint(1, 30)) + ) + + session.add(customer) + customers.append(customer) + + await session.commit() + return customers + +async def create_orders(session: AsyncSession, customers: list[Customer]): + """Create sample orders in different statuses""" + order_statuses = [ + "pending", "confirmed", "in_production", "ready", + "out_for_delivery", "delivered", "cancelled" + ] + + order_types = ["standard", "rush", "recurring", "special"] + priorities = ["low", "normal", "high"] + delivery_methods = ["delivery", "pickup"] + payment_statuses = ["pending", "partial", "paid", "failed"] + + for i in range(25): # Create 25 sample orders + customer = random.choice(customers) + order_status = random.choice(order_statuses) + + # Create order date in the last 30 days + order_date = datetime.now() - timedelta(days=random.randint(0, 30)) + + # Create delivery date (1-7 days after order date) + delivery_date = order_date + timedelta(days=random.randint(1, 7)) + + order = CustomerOrder( + tenant_id=TEST_TENANT_ID, + order_number=f"ORD-{datetime.now().year}-{i+1:04d}", + customer_id=customer.id, + status=order_status, + order_type=random.choice(order_types), + priority=random.choice(priorities), + order_date=order_date, + requested_delivery_date=delivery_date, + confirmed_delivery_date=delivery_date if order_status not in ["pending", "cancelled"] else None, + actual_delivery_date=delivery_date if order_status == "delivered" else None, + delivery_method=random.choice(delivery_methods), + delivery_instructions=random.choice([ + None, "Dejar en recepción", "Llamar al timbre", "Cuidado con el escalón" + ]), + discount_percentage=customer.discount_percentage, + payment_status=random.choice(payment_statuses) if order_status != "cancelled" else "failed", + payment_method=random.choice(["cash", "card", "bank_transfer"]), + payment_terms=customer.payment_terms, + special_instructions=random.choice([ + None, "Sin gluten", "Decoración especial", "Entrega temprano", "Cliente VIP" + ]), + order_source=random.choice(["manual", "online", "phone"]), + sales_channel=random.choice(["direct", "wholesale"]), + customer_notified_confirmed=order_status not in ["pending", "cancelled"], + customer_notified_ready=order_status in ["ready", "out_for_delivery", "delivered"], + customer_notified_delivered=order_status == "delivered", + quality_score=Decimal(str(random.randint(70, 100) / 10)) if order_status == "delivered" else None, + customer_rating=random.randint(3, 5) if order_status == "delivered" else None + ) + + session.add(order) + await session.flush() # Flush to get the order ID + + # Create order items + num_items = random.randint(1, 5) + subtotal = Decimal("0.00") + + for _ in range(num_items): + product = random.choice(SAMPLE_PRODUCTS) + quantity = random.randint(1, 10) + unit_price = product["price"] + line_total = unit_price * quantity + + order_item = OrderItem( + order_id=order.id, + product_id=product["id"], + product_name=product["name"], + product_category=product["category"], + quantity=quantity, + unit_of_measure="unidad", + unit_price=unit_price, + line_discount=Decimal("0.00"), + line_total=line_total, + status=order_status if order_status != "cancelled" else "cancelled" + ) + + session.add(order_item) + subtotal += line_total + + # Calculate financial totals + discount_amount = subtotal * (order.discount_percentage / 100) + tax_amount = (subtotal - discount_amount) * Decimal("0.21") # 21% VAT + delivery_fee = Decimal("3.50") if order.delivery_method == "delivery" and subtotal < 25 else Decimal("0.00") + total_amount = subtotal - discount_amount + tax_amount + delivery_fee + + # Update order with calculated totals + order.subtotal = subtotal + order.discount_amount = discount_amount + order.tax_amount = tax_amount + order.delivery_fee = delivery_fee + order.total_amount = total_amount + + # Create status history + status_history = OrderStatusHistory( + order_id=order.id, + from_status=None, + to_status=order_status, + event_type="status_change", + event_description=f"Order created with status: {order_status}", + change_source="system", + changed_at=order_date, + customer_notified=order_status != "pending" + ) + + session.add(status_history) + + # Add additional status changes for non-pending orders + if order_status != "pending": + current_date = order_date + for status in ["confirmed", "in_production", "ready"]: + if order_statuses.index(status) <= order_statuses.index(order_status): + current_date += timedelta(hours=random.randint(2, 12)) + status_change = OrderStatusHistory( + order_id=order.id, + from_status="pending" if status == "confirmed" else None, + to_status=status, + event_type="status_change", + event_description=f"Order status changed to: {status}", + change_source="manual", + changed_at=current_date, + customer_notified=True + ) + session.add(status_change) + + await session.commit() + +async def main(): + """Main function to seed the database""" + print("🌱 Starting database seeding...") + + async for session in get_session(): + try: + print("📋 Creating customers...") + customers = await create_customers(session) + print(f"✅ Created {len(customers)} customers") + + print("📦 Creating orders...") + await create_orders(session, customers) + print("✅ Created orders with different statuses") + + print("🎉 Database seeding completed successfully!") + + except Exception as e: + print(f"❌ Error during seeding: {e}") + await session.rollback() + raise + finally: + await session.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file