Add order page with real API calls
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
695
frontend/src/components/domain/orders/OrderFormModal.tsx
Normal file
695
frontend/src/components/domain/orders/OrderFormModal.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
|
||||
export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
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<CustomerResponse | null>(null);
|
||||
const [orderItems, setOrderItems] = useState<OrderItemCreate[]>([]);
|
||||
const [orderData, setOrderData] = useState<Partial<OrderCreate>>({
|
||||
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<Partial<CustomerCreate>>({
|
||||
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<string>('');
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nuevo Pedido"
|
||||
subtitle="Complete los detalles del pedido"
|
||||
sections={modalSections}
|
||||
size="2xl"
|
||||
showDefaultActions={false}
|
||||
actions={[
|
||||
{
|
||||
label: selectedCustomer ? 'Cambiar Cliente' : 'Seleccionar Cliente',
|
||||
variant: 'outline',
|
||||
onClick: () => 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 */}
|
||||
<div className="hidden">
|
||||
|
||||
</div>
|
||||
|
||||
{/* Product Selection Modal - Using StatusModal for consistency */}
|
||||
<StatusModal
|
||||
isOpen={showProductModal}
|
||||
onClose={() => 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: (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
|
||||
{productsLoading ? (
|
||||
<div className="col-span-2 text-center py-8">
|
||||
<p className="text-[var(--text-secondary)]">Cargando productos...</p>
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="col-span-2 text-center py-8">
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{productSearch ? 'No se encontraron productos que coincidan con la búsqueda' : 'No hay productos finalizados disponibles'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredProducts.map(product => (
|
||||
<div key={product.id} className="border border-[var(--border-primary)] rounded-lg p-4 hover:bg-[var(--bg-secondary)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{product.name}</h4>
|
||||
{product.description && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">{product.description}</p>
|
||||
)}
|
||||
<div className="flex items-center mt-2 flex-wrap gap-2">
|
||||
{product.category && (
|
||||
<Badge variant="soft" color="gray">
|
||||
{product.category}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-lg font-semibold text-[var(--color-info)]">
|
||||
€{(product.average_cost || product.standard_cost || 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddProduct(product)}
|
||||
disabled={!product.is_active || (product.total_quantity || 0) <= 0}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Agregar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-2 flex justify-between">
|
||||
<span>Stock: {product.total_quantity || 0} {product.unit_of_measure}</span>
|
||||
{product.sku && <span>SKU: {product.sku}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
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 */}
|
||||
<StatusModal
|
||||
isOpen={showCustomerForm}
|
||||
onClose={() => 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 */}
|
||||
<StatusModal
|
||||
isOpen={showCustomerSelector}
|
||||
onClose={() => 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: (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{customers.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<p>No hay clientes disponibles</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCustomerSelector(false);
|
||||
setShowCustomerForm(true);
|
||||
}}
|
||||
className="mt-2"
|
||||
>
|
||||
Crear Primer Cliente
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
customers.map(customer => (
|
||||
<div key={customer.id} className="border border-[var(--border-primary)] rounded-lg p-4 hover:bg-[var(--bg-secondary)]">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{customer.name}</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Código: {customer.customer_code}
|
||||
</p>
|
||||
<div className="text-sm text-[var(--text-secondary)] space-y-1 mt-2">
|
||||
{customer.email && <div>📧 {customer.email}</div>}
|
||||
{customer.phone && <div>📞 {customer.phone}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedCustomer(customer);
|
||||
setShowCustomerSelector(false);
|
||||
}}
|
||||
variant={selectedCustomer?.id === customer.id ? 'primary' : 'outline'}
|
||||
>
|
||||
{selectedCustomer?.id === customer.id ? 'Seleccionado' : 'Seleccionar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Nuevo Cliente',
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setShowCustomerSelector(false);
|
||||
setShowCustomerForm(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Cerrar',
|
||||
variant: 'primary',
|
||||
onClick: () => setShowCustomerSelector(false)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderFormModal;
|
||||
1
frontend/src/components/domain/orders/index.ts
Normal file
1
frontend/src/components/domain/orders/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OrderFormModal } from './OrderFormModal';
|
||||
@@ -110,12 +110,12 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
return (
|
||||
<Card
|
||||
className={`
|
||||
p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.02]' : ''}
|
||||
p-4 sm:p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.01]' : ''}
|
||||
${statusIndicator.isCritical
|
||||
? 'ring-2 ring-red-200 shadow-md border-l-8'
|
||||
? 'ring-2 ring-red-200 shadow-md border-l-6 sm:border-l-8'
|
||||
: statusIndicator.isHighlight
|
||||
? 'ring-1 ring-yellow-200'
|
||||
? 'ring-1 ring-yellow-200 border-l-6'
|
||||
: ''
|
||||
}
|
||||
${className}
|
||||
@@ -130,30 +130,30 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
{/* Header with status indicator */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div
|
||||
className={`flex-shrink-0 p-3 rounded-xl shadow-sm ${
|
||||
className={`flex-shrink-0 p-2 sm:p-3 rounded-xl shadow-sm ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-white' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: `${statusIndicator.color}20` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
className="w-4 h-4 sm:w-5 sm:h-5"
|
||||
style={{ color: statusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--text-primary)] text-lg leading-tight mb-1">
|
||||
<div className="font-semibold text-[var(--text-primary)] text-base sm:text-lg leading-tight mb-1 truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold transition-all ${
|
||||
className={`inline-flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs font-semibold transition-all ${
|
||||
statusIndicator.isCritical
|
||||
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
|
||||
: statusIndicator.isHighlight
|
||||
@@ -186,25 +186,25 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] leading-none">
|
||||
<div className="text-right flex-shrink-0 ml-4 min-w-0">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none truncate">
|
||||
{primaryValue}
|
||||
</div>
|
||||
{primaryValueLabel && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1">
|
||||
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1 truncate">
|
||||
{primaryValueLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary info */}
|
||||
{/* Secondary info - Mobile optimized */}
|
||||
{secondaryInfo && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
<div className="flex items-center justify-between text-sm gap-2">
|
||||
<span className="text-[var(--text-secondary)] truncate flex-shrink-0">
|
||||
{secondaryInfo.label}
|
||||
</span>
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
<span className="font-medium text-[var(--text-primary)] truncate text-right">
|
||||
{secondaryInfo.value}
|
||||
</span>
|
||||
</div>
|
||||
@@ -228,36 +228,36 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
)}
|
||||
|
||||
|
||||
{/* Metadata */}
|
||||
{/* Metadata - Improved mobile layout */}
|
||||
{metadata.length > 0 && (
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={index}>{item}</div>
|
||||
<div key={index} className="truncate" title={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simplified Action System */}
|
||||
{/* Simplified Action System - Mobile optimized */}
|
||||
{actions.length > 0 && (
|
||||
<div className="pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="pt-3 sm:pt-4 border-t border-[var(--border-primary)]">
|
||||
{/* All actions in a clean horizontal layout */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
|
||||
{/* Primary action as a subtle text button */}
|
||||
{primaryActions.length > 0 && (
|
||||
<button
|
||||
onClick={primaryActions[0].onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95
|
||||
flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 text-xs sm:text-sm font-medium rounded-lg
|
||||
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0
|
||||
${primaryActions[0].destructive
|
||||
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
|
||||
: 'text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)] hover:text-[var(--color-primary-700)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4" })}
|
||||
<span>{primaryActions[0].label}</span>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
|
||||
<span className="truncate">{primaryActions[0].label}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -269,14 +269,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
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" })}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -287,14 +287,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
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" })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<AuthProviderProps> = ({ 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<AuthProviderProps> = ({ 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,
|
||||
|
||||
106
frontend/src/locales/es/orders.json
Normal file
106
frontend/src/locales/es/orders.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -256,16 +256,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<T>(
|
||||
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}`)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user