1374 lines
51 KiB
TypeScript
1374 lines
51 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||
|
|
import {
|
|||
|
|
Card,
|
|||
|
|
Button,
|
|||
|
|
Input,
|
|||
|
|
Select,
|
|||
|
|
Modal,
|
|||
|
|
Badge,
|
|||
|
|
Tooltip
|
|||
|
|
} from '../../ui';
|
|||
|
|
import {
|
|||
|
|
SalesChannel,
|
|||
|
|
PaymentMethod
|
|||
|
|
} from '../../../types/sales.types';
|
|||
|
|
import { salesService } from '../../../services/api/sales.service';
|
|||
|
|
|
|||
|
|
// Order form interfaces
|
|||
|
|
interface Product {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
description?: string;
|
|||
|
|
category: string;
|
|||
|
|
price: number;
|
|||
|
|
available_quantity: number;
|
|||
|
|
image_url?: string;
|
|||
|
|
allergens: string[];
|
|||
|
|
nutritional_info?: NutritionalInfo;
|
|||
|
|
preparation_time: number; // in minutes
|
|||
|
|
is_available: boolean;
|
|||
|
|
tags: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface NutritionalInfo {
|
|||
|
|
calories_per_100g: number;
|
|||
|
|
protein: number;
|
|||
|
|
carbs: number;
|
|||
|
|
fat: number;
|
|||
|
|
fiber: number;
|
|||
|
|
sugar: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Customer {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
email?: string;
|
|||
|
|
phone?: string;
|
|||
|
|
address?: string;
|
|||
|
|
city?: string;
|
|||
|
|
postal_code?: string;
|
|||
|
|
loyalty_points: number;
|
|||
|
|
preferred_channel: SalesChannel;
|
|||
|
|
notes?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OrderItem {
|
|||
|
|
product_id: string;
|
|||
|
|
product_name: string;
|
|||
|
|
quantity: number;
|
|||
|
|
unit_price: number;
|
|||
|
|
total_price: number;
|
|||
|
|
special_instructions?: string;
|
|||
|
|
customizations?: OrderCustomization[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OrderCustomization {
|
|||
|
|
name: string;
|
|||
|
|
value: string;
|
|||
|
|
price_adjustment: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface DeliveryInfo {
|
|||
|
|
type: DeliveryType;
|
|||
|
|
address?: string;
|
|||
|
|
city?: string;
|
|||
|
|
postal_code?: string;
|
|||
|
|
delivery_date?: string;
|
|||
|
|
delivery_time_slot?: string;
|
|||
|
|
delivery_notes?: string;
|
|||
|
|
delivery_fee: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OrderFormData {
|
|||
|
|
customer?: Customer;
|
|||
|
|
items: OrderItem[];
|
|||
|
|
sales_channel: SalesChannel;
|
|||
|
|
payment_method?: PaymentMethod;
|
|||
|
|
delivery_info?: DeliveryInfo;
|
|||
|
|
special_instructions?: string;
|
|||
|
|
discount_code?: string;
|
|||
|
|
discount_amount: number;
|
|||
|
|
tax_rate: number;
|
|||
|
|
subtotal: number;
|
|||
|
|
tax_amount: number;
|
|||
|
|
delivery_fee: number;
|
|||
|
|
total_amount: number;
|
|||
|
|
scheduled_date?: string;
|
|||
|
|
scheduled_time?: string;
|
|||
|
|
loyalty_points_to_use: number;
|
|||
|
|
notes?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enum DeliveryType {
|
|||
|
|
PICKUP = 'pickup',
|
|||
|
|
HOME_DELIVERY = 'home_delivery',
|
|||
|
|
SCHEDULED_PICKUP = 'scheduled_pickup'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enum PaymentMethodType {
|
|||
|
|
CASH = 'cash',
|
|||
|
|
CREDIT_CARD = 'credit_card',
|
|||
|
|
DEBIT_CARD = 'debit_card',
|
|||
|
|
DIGITAL_WALLET = 'digital_wallet',
|
|||
|
|
BANK_TRANSFER = 'bank_transfer',
|
|||
|
|
LOYALTY_POINTS = 'loyalty_points',
|
|||
|
|
STORE_CREDIT = 'store_credit'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OrderFormProps {
|
|||
|
|
orderId?: string; // For editing existing orders
|
|||
|
|
initialCustomer?: Customer;
|
|||
|
|
onOrderSave?: (orderData: OrderFormData) => Promise<boolean>;
|
|||
|
|
onOrderCancel?: () => void;
|
|||
|
|
allowCustomerCreation?: boolean;
|
|||
|
|
showPricing?: boolean;
|
|||
|
|
className?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const ChannelLabels = {
|
|||
|
|
[SalesChannel.STORE_FRONT]: 'Tienda Física',
|
|||
|
|
[SalesChannel.ONLINE]: 'Tienda Online',
|
|||
|
|
[SalesChannel.PHONE_ORDER]: 'Pedido Telefónico',
|
|||
|
|
[SalesChannel.DELIVERY]: 'Servicio Delivery',
|
|||
|
|
[SalesChannel.CATERING]: 'Catering',
|
|||
|
|
[SalesChannel.WHOLESALE]: 'Venta Mayorista',
|
|||
|
|
[SalesChannel.FARMERS_MARKET]: 'Mercado Local',
|
|||
|
|
[SalesChannel.THIRD_PARTY]: 'Plataforma Terceros'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PaymentMethodLabels = {
|
|||
|
|
[PaymentMethodType.CASH]: 'Efectivo',
|
|||
|
|
[PaymentMethodType.CREDIT_CARD]: 'Tarjeta de Crédito',
|
|||
|
|
[PaymentMethodType.DEBIT_CARD]: 'Tarjeta de Débito',
|
|||
|
|
[PaymentMethodType.DIGITAL_WALLET]: 'Wallet Digital',
|
|||
|
|
[PaymentMethodType.BANK_TRANSFER]: 'Transferencia',
|
|||
|
|
[PaymentMethodType.LOYALTY_POINTS]: 'Puntos de Fidelización',
|
|||
|
|
[PaymentMethodType.STORE_CREDIT]: 'Crédito de Tienda'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const DeliveryTypeLabels = {
|
|||
|
|
[DeliveryType.PICKUP]: 'Recogida en Tienda',
|
|||
|
|
[DeliveryType.HOME_DELIVERY]: 'Entrega a Domicilio',
|
|||
|
|
[DeliveryType.SCHEDULED_PICKUP]: 'Recogida Programada'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Mock data for bakery products
|
|||
|
|
const mockProducts: Product[] = [
|
|||
|
|
{
|
|||
|
|
id: 'pan-integral-001',
|
|||
|
|
name: 'Pan Integral Artesano',
|
|||
|
|
description: 'Pan elaborado con harina integral 100%, masa madre natural',
|
|||
|
|
category: 'Panadería',
|
|||
|
|
price: 2.50,
|
|||
|
|
available_quantity: 25,
|
|||
|
|
allergens: ['gluten'],
|
|||
|
|
preparation_time: 180,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['artesano', 'integral', 'masa-madre']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'croissant-mantequilla-002',
|
|||
|
|
name: 'Croissant de Mantequilla',
|
|||
|
|
description: 'Croissant francés tradicional con mantequilla de Normandía',
|
|||
|
|
category: 'Bollería',
|
|||
|
|
price: 1.80,
|
|||
|
|
available_quantity: 40,
|
|||
|
|
allergens: ['gluten', 'lactosa', 'huevo'],
|
|||
|
|
preparation_time: 240,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['frances', 'mantequilla', 'tradicional']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'tarta-santiago-003',
|
|||
|
|
name: 'Tarta de Santiago',
|
|||
|
|
description: 'Tarta tradicional gallega con almendra marcona',
|
|||
|
|
category: 'Repostería',
|
|||
|
|
price: 18.90,
|
|||
|
|
available_quantity: 8,
|
|||
|
|
allergens: ['almendra', 'huevo'],
|
|||
|
|
preparation_time: 360,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['tradicional', 'almendra', 'gallega']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'magdalenas-limon-004',
|
|||
|
|
name: 'Magdalenas de Limón',
|
|||
|
|
description: 'Magdalenas esponjosas con ralladura de limón ecológico',
|
|||
|
|
category: 'Bollería',
|
|||
|
|
price: 0.90,
|
|||
|
|
available_quantity: 60,
|
|||
|
|
allergens: ['gluten', 'huevo', 'lactosa'],
|
|||
|
|
preparation_time: 45,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['limon', 'esponjosas', 'ecologico']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'empanada-atun-005',
|
|||
|
|
name: 'Empanada de Atún',
|
|||
|
|
description: 'Empanada gallega rellena de atún, pimiento y huevo',
|
|||
|
|
category: 'Salado',
|
|||
|
|
price: 12.50,
|
|||
|
|
available_quantity: 6,
|
|||
|
|
allergens: ['gluten', 'pescado', 'huevo'],
|
|||
|
|
preparation_time: 300,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['gallega', 'atun', 'tradicional']
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'brownie-chocolate-006',
|
|||
|
|
name: 'Brownie de Chocolate Negro',
|
|||
|
|
description: 'Brownie intenso con chocolate negro 70% y nueces',
|
|||
|
|
category: 'Repostería',
|
|||
|
|
price: 3.20,
|
|||
|
|
available_quantity: 20,
|
|||
|
|
allergens: ['gluten', 'huevo', 'frutos-secos', 'lactosa'],
|
|||
|
|
preparation_time: 60,
|
|||
|
|
is_available: true,
|
|||
|
|
tags: ['chocolate', 'nueces', 'intenso']
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
const mockCustomers: Customer[] = [
|
|||
|
|
{
|
|||
|
|
id: 'customer-001',
|
|||
|
|
name: 'María García López',
|
|||
|
|
email: 'maria.garcia@email.com',
|
|||
|
|
phone: '+34 612 345 678',
|
|||
|
|
address: 'Calle Mayor, 123',
|
|||
|
|
city: 'Madrid',
|
|||
|
|
postal_code: '28001',
|
|||
|
|
loyalty_points: 2450,
|
|||
|
|
preferred_channel: SalesChannel.ONLINE,
|
|||
|
|
notes: 'Cliente VIP. Prefiere productos sin gluten.'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'customer-002',
|
|||
|
|
name: 'Carlos Rodríguez Martín',
|
|||
|
|
email: 'carlos.rodriguez@email.com',
|
|||
|
|
phone: '+34 687 654 321',
|
|||
|
|
address: 'Avenida de la Constitución, 45',
|
|||
|
|
city: 'Madrid',
|
|||
|
|
postal_code: '28002',
|
|||
|
|
loyalty_points: 1230,
|
|||
|
|
preferred_channel: SalesChannel.STORE_FRONT
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'customer-003',
|
|||
|
|
name: 'Ana Fernández Ruiz',
|
|||
|
|
email: 'ana.fernandez@email.com',
|
|||
|
|
phone: '+34 698 765 432',
|
|||
|
|
address: 'Plaza de España, 12',
|
|||
|
|
city: 'Madrid',
|
|||
|
|
postal_code: '28008',
|
|||
|
|
loyalty_points: 890,
|
|||
|
|
preferred_channel: SalesChannel.DELIVERY
|
|||
|
|
}
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export const OrderForm: React.FC<OrderFormProps> = ({
|
|||
|
|
orderId,
|
|||
|
|
initialCustomer,
|
|||
|
|
onOrderSave,
|
|||
|
|
onOrderCancel,
|
|||
|
|
allowCustomerCreation = true,
|
|||
|
|
showPricing = true,
|
|||
|
|
className = ''
|
|||
|
|
}) => {
|
|||
|
|
// Form data state
|
|||
|
|
const [orderData, setOrderData] = useState<OrderFormData>({
|
|||
|
|
customer: initialCustomer,
|
|||
|
|
items: [],
|
|||
|
|
sales_channel: SalesChannel.STORE_FRONT,
|
|||
|
|
discount_amount: 0,
|
|||
|
|
tax_rate: 0.21, // 21% IVA in Spain
|
|||
|
|
subtotal: 0,
|
|||
|
|
tax_amount: 0,
|
|||
|
|
delivery_fee: 0,
|
|||
|
|
total_amount: 0,
|
|||
|
|
loyalty_points_to_use: 0
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// UI state
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
// Product selection
|
|||
|
|
const [products, setProducts] = useState<Product[]>(mockProducts);
|
|||
|
|
const [filteredProducts, setFilteredProducts] = useState<Product[]>(mockProducts);
|
|||
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('');
|
|||
|
|
const [productSearchTerm, setProductSearchTerm] = useState('');
|
|||
|
|
const [showProductModal, setShowProductModal] = useState(false);
|
|||
|
|
|
|||
|
|
// Customer management
|
|||
|
|
const [customers, setCustomers] = useState<Customer[]>(mockCustomers);
|
|||
|
|
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
|
|||
|
|
const [showCustomerModal, setShowCustomerModal] = useState(false);
|
|||
|
|
const [newCustomerForm, setNewCustomerForm] = useState<Partial<Customer>>({});
|
|||
|
|
const [showNewCustomerForm, setShowNewCustomerForm] = useState(false);
|
|||
|
|
|
|||
|
|
// Validation
|
|||
|
|
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
|
|||
|
|
|
|||
|
|
// Available categories
|
|||
|
|
const categories = useMemo(() => {
|
|||
|
|
const cats = [...new Set(products.map(p => p.category))];
|
|||
|
|
return cats;
|
|||
|
|
}, [products]);
|
|||
|
|
|
|||
|
|
// Available time slots for delivery/pickup
|
|||
|
|
const timeSlots = [
|
|||
|
|
'09:00 - 10:00', '10:00 - 11:00', '11:00 - 12:00',
|
|||
|
|
'12:00 - 13:00', '13:00 - 14:00', '15:00 - 16:00',
|
|||
|
|
'16:00 - 17:00', '17:00 - 18:00', '18:00 - 19:00'
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Filter products
|
|||
|
|
useEffect(() => {
|
|||
|
|
let filtered = products.filter(product => product.is_available);
|
|||
|
|
|
|||
|
|
if (selectedCategory) {
|
|||
|
|
filtered = filtered.filter(product => product.category === selectedCategory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (productSearchTerm) {
|
|||
|
|
const searchLower = productSearchTerm.toLowerCase();
|
|||
|
|
filtered = filtered.filter(product =>
|
|||
|
|
product.name.toLowerCase().includes(searchLower) ||
|
|||
|
|
product.description?.toLowerCase().includes(searchLower) ||
|
|||
|
|
product.tags.some(tag => tag.toLowerCase().includes(searchLower))
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setFilteredProducts(filtered);
|
|||
|
|
}, [products, selectedCategory, productSearchTerm]);
|
|||
|
|
|
|||
|
|
// Calculate totals
|
|||
|
|
useEffect(() => {
|
|||
|
|
const subtotal = orderData.items.reduce((sum, item) => sum + item.total_price, 0);
|
|||
|
|
const discountAmount = orderData.discount_code ? calculateDiscount(subtotal, orderData.discount_code) : 0;
|
|||
|
|
const subtotalAfterDiscount = subtotal - discountAmount;
|
|||
|
|
const taxAmount = subtotalAfterDiscount * orderData.tax_rate;
|
|||
|
|
const deliveryFee = calculateDeliveryFee();
|
|||
|
|
const loyaltyPointsDiscount = orderData.loyalty_points_to_use * 0.01; // 1 point = 1 cent
|
|||
|
|
const totalAmount = Math.max(0, subtotalAfterDiscount + taxAmount + deliveryFee - loyaltyPointsDiscount);
|
|||
|
|
|
|||
|
|
setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
subtotal,
|
|||
|
|
discount_amount: discountAmount,
|
|||
|
|
tax_amount: taxAmount,
|
|||
|
|
delivery_fee: deliveryFee,
|
|||
|
|
total_amount: totalAmount
|
|||
|
|
}));
|
|||
|
|
}, [orderData.items, orderData.discount_code, orderData.loyalty_points_to_use, orderData.delivery_info, orderData.tax_rate]);
|
|||
|
|
|
|||
|
|
// Load existing order for editing
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (orderId) {
|
|||
|
|
loadExistingOrder(orderId);
|
|||
|
|
}
|
|||
|
|
}, [orderId]);
|
|||
|
|
|
|||
|
|
// Functions
|
|||
|
|
const loadExistingOrder = async (id: string) => {
|
|||
|
|
setLoading(true);
|
|||
|
|
try {
|
|||
|
|
// In real app, load from API
|
|||
|
|
// const response = await salesService.getOrder(id);
|
|||
|
|
console.log('Loading order:', id);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError('Error al cargar el pedido');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calculateDiscount = (subtotal: number, discountCode: string): number => {
|
|||
|
|
// Mock discount calculation
|
|||
|
|
const discountCodes = {
|
|||
|
|
'BIENVENIDO10': 0.10, // 10%
|
|||
|
|
'FIDELIDAD15': 0.15, // 15%
|
|||
|
|
'CUMPLE20': 0.20 // 20%
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const discountRate = discountCodes[discountCode as keyof typeof discountCodes] || 0;
|
|||
|
|
return subtotal * discountRate;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calculateDeliveryFee = (): number => {
|
|||
|
|
if (!orderData.delivery_info || orderData.delivery_info.type === DeliveryType.PICKUP) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (orderData.delivery_info.type === DeliveryType.SCHEDULED_PICKUP) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Base delivery fee
|
|||
|
|
let fee = 3.50;
|
|||
|
|
|
|||
|
|
// Free delivery for orders over €25
|
|||
|
|
if (orderData.subtotal >= 25) {
|
|||
|
|
fee = 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return fee;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const addProductToOrder = (product: Product, quantity: number = 1) => {
|
|||
|
|
const existingItemIndex = orderData.items.findIndex(item => item.product_id === product.id);
|
|||
|
|
|
|||
|
|
if (existingItemIndex >= 0) {
|
|||
|
|
// Update existing item
|
|||
|
|
const updatedItems = [...orderData.items];
|
|||
|
|
updatedItems[existingItemIndex].quantity += quantity;
|
|||
|
|
updatedItems[existingItemIndex].total_price =
|
|||
|
|
updatedItems[existingItemIndex].quantity * updatedItems[existingItemIndex].unit_price;
|
|||
|
|
|
|||
|
|
setOrderData(prev => ({ ...prev, items: updatedItems }));
|
|||
|
|
} else {
|
|||
|
|
// Add new item
|
|||
|
|
const newItem: OrderItem = {
|
|||
|
|
product_id: product.id,
|
|||
|
|
product_name: product.name,
|
|||
|
|
quantity,
|
|||
|
|
unit_price: product.price,
|
|||
|
|
total_price: product.price * quantity
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
items: [...prev.items, newItem]
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setShowProductModal(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const updateOrderItem = (productId: string, updates: Partial<OrderItem>) => {
|
|||
|
|
const updatedItems = orderData.items.map(item => {
|
|||
|
|
if (item.product_id === productId) {
|
|||
|
|
const updatedItem = { ...item, ...updates };
|
|||
|
|
if (updates.quantity !== undefined) {
|
|||
|
|
updatedItem.total_price = updatedItem.quantity * updatedItem.unit_price;
|
|||
|
|
}
|
|||
|
|
return updatedItem;
|
|||
|
|
}
|
|||
|
|
return item;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setOrderData(prev => ({ ...prev, items: updatedItems }));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const removeOrderItem = (productId: string) => {
|
|||
|
|
const updatedItems = orderData.items.filter(item => item.product_id !== productId);
|
|||
|
|
setOrderData(prev => ({ ...prev, items: updatedItems }));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const selectCustomer = (customer: Customer) => {
|
|||
|
|
setOrderData(prev => ({ ...prev, customer }));
|
|||
|
|
setShowCustomerModal(false);
|
|||
|
|
setCustomerSearchTerm('');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const createNewCustomer = async () => {
|
|||
|
|
if (!newCustomerForm.name || !newCustomerForm.phone) {
|
|||
|
|
setError('Nombre y teléfono son obligatorios');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const newCustomer: Customer = {
|
|||
|
|
id: `customer-${Date.now()}`,
|
|||
|
|
name: newCustomerForm.name,
|
|||
|
|
email: newCustomerForm.email,
|
|||
|
|
phone: newCustomerForm.phone,
|
|||
|
|
address: newCustomerForm.address,
|
|||
|
|
city: newCustomerForm.city || 'Madrid',
|
|||
|
|
postal_code: newCustomerForm.postal_code,
|
|||
|
|
loyalty_points: 0,
|
|||
|
|
preferred_channel: orderData.sales_channel,
|
|||
|
|
notes: newCustomerForm.notes
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
setCustomers(prev => [...prev, newCustomer]);
|
|||
|
|
setOrderData(prev => ({ ...prev, customer: newCustomer }));
|
|||
|
|
setNewCustomerForm({});
|
|||
|
|
setShowNewCustomerForm(false);
|
|||
|
|
setShowCustomerModal(false);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const validateForm = (): boolean => {
|
|||
|
|
const errors: Record<string, string> = {};
|
|||
|
|
|
|||
|
|
if (orderData.items.length === 0) {
|
|||
|
|
errors.items = 'Debe agregar al menos un producto al pedido';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!orderData.customer) {
|
|||
|
|
errors.customer = 'Debe seleccionar un cliente';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (orderData.delivery_info?.type === DeliveryType.HOME_DELIVERY) {
|
|||
|
|
if (!orderData.delivery_info.address) {
|
|||
|
|
errors.delivery_address = 'La dirección de entrega es obligatoria';
|
|||
|
|
}
|
|||
|
|
if (!orderData.delivery_info.delivery_date) {
|
|||
|
|
errors.delivery_date = 'La fecha de entrega es obligatoria';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (orderData.delivery_info?.type === DeliveryType.SCHEDULED_PICKUP) {
|
|||
|
|
if (!orderData.scheduled_date) {
|
|||
|
|
errors.scheduled_date = 'La fecha de recogida es obligatoria';
|
|||
|
|
}
|
|||
|
|
if (!orderData.scheduled_time) {
|
|||
|
|
errors.scheduled_time = 'La hora de recogida es obligatoria';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setValidationErrors(errors);
|
|||
|
|
return Object.keys(errors).length === 0;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSaveOrder = async () => {
|
|||
|
|
if (!validateForm()) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const success = await onOrderSave?.(orderData);
|
|||
|
|
if (success) {
|
|||
|
|
setSuccess('Pedido guardado correctamente');
|
|||
|
|
// Reset form or navigate away
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
setError('Error al guardar el pedido');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className={`flex items-center justify-center p-8 ${className}`}>
|
|||
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|||
|
|
<span className="ml-2 text-[var(--text-secondary)]">
|
|||
|
|
{orderId ? 'Cargando pedido...' : 'Guardando pedido...'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className={`space-y-6 ${className}`}>
|
|||
|
|
{/* Header */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-2xl font-bold text-[var(--text-primary)]">
|
|||
|
|
{orderId ? 'Editar Pedido' : 'Nuevo Pedido'}
|
|||
|
|
</h2>
|
|||
|
|
<p className="text-[var(--text-secondary)]">
|
|||
|
|
{orderId ? `Pedido #${orderId.slice(-8)}` : 'Crear un nuevo pedido'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center space-x-3">
|
|||
|
|
<Button variant="outline" onClick={onOrderCancel}>
|
|||
|
|
Cancelar
|
|||
|
|
</Button>
|
|||
|
|
<Button onClick={handleSaveOrder} disabled={orderData.items.length === 0}>
|
|||
|
|
{orderId ? 'Actualizar Pedido' : 'Crear Pedido'}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|||
|
|
{/* Left Column - Order Details */}
|
|||
|
|
<div className="lg:col-span-2 space-y-6">
|
|||
|
|
{/* Customer Selection */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<div className="flex items-center justify-between mb-4">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Cliente</h3>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setShowCustomerModal(true)}
|
|||
|
|
>
|
|||
|
|
{orderData.customer ? 'Cambiar Cliente' : 'Seleccionar Cliente'}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{orderData.customer ? (
|
|||
|
|
<div className="bg-[var(--color-info)]/5 border border-[var(--color-info)]/20 rounded-lg p-4">
|
|||
|
|
<div className="flex items-start justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="font-medium text-[var(--text-primary)]">{orderData.customer.name}</h4>
|
|||
|
|
<div className="mt-1 text-sm text-[var(--text-secondary)] space-y-1">
|
|||
|
|
{orderData.customer.email && <div>📧 {orderData.customer.email}</div>}
|
|||
|
|
{orderData.customer.phone && <div>📞 {orderData.customer.phone}</div>}
|
|||
|
|
{orderData.customer.address && (
|
|||
|
|
<div>📍 {orderData.customer.address}, {orderData.customer.city}</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-2 flex items-center space-x-4">
|
|||
|
|
<Badge variant="soft" color="gold">
|
|||
|
|
{orderData.customer.loyalty_points} puntos
|
|||
|
|
</Badge>
|
|||
|
|
<Badge variant="soft" color="blue">
|
|||
|
|
{ChannelLabels[orderData.customer.preferred_channel]}
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="text-center py-6 text-[var(--text-tertiary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|||
|
|
<svg className="w-12 h-12 mx-auto mb-2 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|||
|
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|||
|
|
</svg>
|
|||
|
|
<p>No hay cliente seleccionado</p>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setShowCustomerModal(true)}
|
|||
|
|
className="mt-2"
|
|||
|
|
>
|
|||
|
|
Seleccionar Cliente
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{validationErrors.customer && (
|
|||
|
|
<p className="mt-2 text-sm text-[var(--color-error)]">{validationErrors.customer}</p>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* Product Selection */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<div className="flex items-center justify-between mb-4">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Productos</h3>
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setShowProductModal(true)}
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|||
|
|
</svg>
|
|||
|
|
Agregar Producto
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{orderData.items.length === 0 ? (
|
|||
|
|
<div className="text-center py-8 text-[var(--text-tertiary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|||
|
|
<svg className="w-12 h-12 mx-auto mb-2 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|||
|
|
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
|||
|
|
</svg>
|
|||
|
|
<p>No hay productos en el pedido</p>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => setShowProductModal(true)}
|
|||
|
|
className="mt-2"
|
|||
|
|
>
|
|||
|
|
Agregar Primer Producto
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
orderData.items.map((item) => (
|
|||
|
|
<div key={item.product_id} className="flex items-center justify-between p-4 border border-[var(--border-primary)] rounded-lg">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<h4 className="font-medium text-[var(--text-primary)]">{item.product_name}</h4>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
€{item.unit_price.toFixed(2)} × {item.quantity} = €{item.total_price.toFixed(2)}
|
|||
|
|
</p>
|
|||
|
|
{item.special_instructions && (
|
|||
|
|
<p className="text-sm text-[var(--color-primary)] mt-1">
|
|||
|
|
📝 {item.special_instructions}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center space-x-2 ml-4">
|
|||
|
|
<div className="flex items-center border border-[var(--border-secondary)] rounded">
|
|||
|
|
<button
|
|||
|
|
onClick={() => updateOrderItem(item.product_id, { quantity: Math.max(1, item.quantity - 1) })}
|
|||
|
|
className="p-1 hover:bg-[var(--bg-tertiary)]"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
<span className="px-3 py-1 text-sm">{item.quantity}</span>
|
|||
|
|
<button
|
|||
|
|
onClick={() => updateOrderItem(item.product_id, { quantity: item.quantity + 1 })}
|
|||
|
|
className="p-1 hover:bg-[var(--bg-tertiary)]"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|||
|
|
</svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Tooltip content="Eliminar producto">
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => removeOrderItem(item.product_id)}
|
|||
|
|
className="text-[var(--color-error)] hover:text-[var(--color-error)]"
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|||
|
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|||
|
|
</svg>
|
|||
|
|
</Button>
|
|||
|
|
</Tooltip>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{validationErrors.items && (
|
|||
|
|
<p className="mt-2 text-sm text-[var(--color-error)]">{validationErrors.items}</p>
|
|||
|
|
)}
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* Delivery & Pickup Options */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Entrega y Recogida</h3>
|
|||
|
|
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Tipo de Servicio</label>
|
|||
|
|
<Select
|
|||
|
|
value={orderData.delivery_info?.type || DeliveryType.PICKUP}
|
|||
|
|
onChange={(value) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info,
|
|||
|
|
type: value as DeliveryType,
|
|||
|
|
delivery_fee: 0
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
options={Object.values(DeliveryType).map(type => ({
|
|||
|
|
value: type,
|
|||
|
|
label: DeliveryTypeLabels[type]
|
|||
|
|
}))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{orderData.delivery_info?.type === DeliveryType.HOME_DELIVERY && (
|
|||
|
|
<div className="space-y-4 p-4 bg-[var(--color-info)]/5 rounded-lg">
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<Input
|
|||
|
|
label="Dirección de entrega"
|
|||
|
|
value={orderData.delivery_info.address || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info!,
|
|||
|
|
address: e.target.value
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
error={validationErrors.delivery_address}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label="Ciudad"
|
|||
|
|
value={orderData.delivery_info.city || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info!,
|
|||
|
|
city: e.target.value
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<Input
|
|||
|
|
label="Fecha de entrega"
|
|||
|
|
type="date"
|
|||
|
|
value={orderData.delivery_info.delivery_date || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info!,
|
|||
|
|
delivery_date: e.target.value
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
min={new Date().toISOString().split('T')[0]}
|
|||
|
|
error={validationErrors.delivery_date}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<Select
|
|||
|
|
label="Franja horaria"
|
|||
|
|
value={orderData.delivery_info.delivery_time_slot || ''}
|
|||
|
|
onChange={(value) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info!,
|
|||
|
|
delivery_time_slot: value
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
options={[
|
|||
|
|
{ value: '', label: 'Seleccionar franja' },
|
|||
|
|
...timeSlots.map(slot => ({ value: slot, label: slot }))
|
|||
|
|
]}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<Input
|
|||
|
|
label="Notas de entrega"
|
|||
|
|
value={orderData.delivery_info.delivery_notes || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
delivery_info: {
|
|||
|
|
...prev.delivery_info!,
|
|||
|
|
delivery_notes: e.target.value
|
|||
|
|
}
|
|||
|
|
}))}
|
|||
|
|
placeholder="Instrucciones especiales para el repartidor..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
|||
|
|
<p className="text-sm text-yellow-800">
|
|||
|
|
💡 <strong>Envío gratuito</strong> en pedidos superiores a €25.
|
|||
|
|
Tu pedido: €{orderData.subtotal.toFixed(2)}
|
|||
|
|
{orderData.subtotal < 25 && (
|
|||
|
|
<span> - Faltan €{(25 - orderData.subtotal).toFixed(2)} para envío gratuito</span>
|
|||
|
|
)}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{orderData.delivery_info?.type === DeliveryType.SCHEDULED_PICKUP && (
|
|||
|
|
<div className="space-y-4 p-4 bg-green-50 rounded-lg">
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<Input
|
|||
|
|
label="Fecha de recogida"
|
|||
|
|
type="date"
|
|||
|
|
value={orderData.scheduled_date || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
scheduled_date: e.target.value
|
|||
|
|
}))}
|
|||
|
|
min={new Date().toISOString().split('T')[0]}
|
|||
|
|
error={validationErrors.scheduled_date}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<Select
|
|||
|
|
label="Hora de recogida"
|
|||
|
|
value={orderData.scheduled_time || ''}
|
|||
|
|
onChange={(value) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
scheduled_time: value
|
|||
|
|
}))}
|
|||
|
|
options={[
|
|||
|
|
{ value: '', label: 'Seleccionar hora' },
|
|||
|
|
...timeSlots.map(slot => ({ value: slot, label: slot }))
|
|||
|
|
]}
|
|||
|
|
error={validationErrors.scheduled_time}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* Payment & Channel */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Pago y Canal</h3>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Canal de Venta</label>
|
|||
|
|
<Select
|
|||
|
|
value={orderData.sales_channel}
|
|||
|
|
onChange={(value) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
sales_channel: value as SalesChannel
|
|||
|
|
}))}
|
|||
|
|
options={Object.values(SalesChannel).map(channel => ({
|
|||
|
|
value: channel,
|
|||
|
|
label: ChannelLabels[channel]
|
|||
|
|
}))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Método de Pago</label>
|
|||
|
|
<Select
|
|||
|
|
value={orderData.payment_method || ''}
|
|||
|
|
onChange={(value) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
payment_method: value as PaymentMethod
|
|||
|
|
}))}
|
|||
|
|
options={[
|
|||
|
|
{ value: '', label: 'Seleccionar método' },
|
|||
|
|
...Object.values(PaymentMethodType).map(method => ({
|
|||
|
|
value: method,
|
|||
|
|
label: PaymentMethodLabels[method]
|
|||
|
|
}))
|
|||
|
|
]}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* Special Instructions */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Instrucciones Especiales</h3>
|
|||
|
|
<textarea
|
|||
|
|
value={orderData.special_instructions || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
special_instructions: e.target.value
|
|||
|
|
}))}
|
|||
|
|
rows={3}
|
|||
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
|||
|
|
placeholder="Alergias, preferencias especiales, instrucciones de preparación..."
|
|||
|
|
/>
|
|||
|
|
</Card>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Right Column - Order Summary */}
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<Card className="p-6 sticky top-4">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Resumen del Pedido</h3>
|
|||
|
|
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex justify-between text-sm">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Subtotal</span>
|
|||
|
|
<span className="font-medium">€{orderData.subtotal.toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{orderData.discount_amount > 0 && (
|
|||
|
|
<div className="flex justify-between text-sm text-[var(--color-success)]">
|
|||
|
|
<span>Descuento{orderData.discount_code && ` (${orderData.discount_code})`}</span>
|
|||
|
|
<span>-€{orderData.discount_amount.toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{orderData.delivery_fee > 0 && (
|
|||
|
|
<div className="flex justify-between text-sm">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Gastos de envío</span>
|
|||
|
|
<span className="font-medium">€{orderData.delivery_fee.toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{orderData.loyalty_points_to_use > 0 && (
|
|||
|
|
<div className="flex justify-between text-sm text-[var(--color-success)]">
|
|||
|
|
<span>Puntos utilizados ({orderData.loyalty_points_to_use})</span>
|
|||
|
|
<span>-€{(orderData.loyalty_points_to_use * 0.01).toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex justify-between text-sm">
|
|||
|
|
<span className="text-[var(--text-secondary)]">IVA ({(orderData.tax_rate * 100).toFixed(0)}%)</span>
|
|||
|
|
<span className="font-medium">€{orderData.tax_amount.toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="border-t pt-3">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-lg font-semibold text-[var(--text-primary)]">Total</span>
|
|||
|
|
<span className="text-lg font-bold text-[var(--color-info)]">€{orderData.total_amount.toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Discount Code */}
|
|||
|
|
<div className="mt-6 pt-6 border-t border-[var(--border-primary)]">
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<Input
|
|||
|
|
label="Código de descuento"
|
|||
|
|
value={orderData.discount_code || ''}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
discount_code: e.target.value.toUpperCase()
|
|||
|
|
}))}
|
|||
|
|
placeholder="BIENVENIDO10"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{orderData.customer && orderData.customer.loyalty_points > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|||
|
|
Usar puntos de fidelización ({orderData.customer.loyalty_points} disponibles)
|
|||
|
|
</label>
|
|||
|
|
<div className="flex items-center space-x-2">
|
|||
|
|
<input
|
|||
|
|
type="range"
|
|||
|
|
min="0"
|
|||
|
|
max={Math.min(orderData.customer.loyalty_points, orderData.subtotal * 100)}
|
|||
|
|
value={orderData.loyalty_points_to_use}
|
|||
|
|
onChange={(e) => setOrderData(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
loyalty_points_to_use: parseInt(e.target.value)
|
|||
|
|
}))}
|
|||
|
|
className="flex-1"
|
|||
|
|
/>
|
|||
|
|
<span className="text-sm text-[var(--text-secondary)] w-16">
|
|||
|
|
{orderData.loyalty_points_to_use} pts
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
|||
|
|
Ahorro: €{(orderData.loyalty_points_to_use * 0.01).toFixed(2)}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Action Buttons */}
|
|||
|
|
<div className="mt-6 space-y-3">
|
|||
|
|
<Button
|
|||
|
|
onClick={handleSaveOrder}
|
|||
|
|
disabled={orderData.items.length === 0 || !orderData.customer}
|
|||
|
|
className="w-full"
|
|||
|
|
>
|
|||
|
|
{orderId ? 'Actualizar Pedido' : 'Crear Pedido'}
|
|||
|
|
</Button>
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={onOrderCancel}
|
|||
|
|
className="w-full"
|
|||
|
|
>
|
|||
|
|
Cancelar
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* Order Progress */}
|
|||
|
|
<Card className="p-6">
|
|||
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Estado del Pedido</h3>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
<div className="flex items-center">
|
|||
|
|
<div className={`w-3 h-3 rounded-full mr-3 ${
|
|||
|
|
orderData.customer ? 'bg-green-500' : 'bg-gray-300'
|
|||
|
|
}`} />
|
|||
|
|
<span className={`text-sm ${
|
|||
|
|
orderData.customer ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-tertiary)]'
|
|||
|
|
}`}>
|
|||
|
|
Cliente seleccionado
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center">
|
|||
|
|
<div className={`w-3 h-3 rounded-full mr-3 ${
|
|||
|
|
orderData.items.length > 0 ? 'bg-green-500' : 'bg-gray-300'
|
|||
|
|
}`} />
|
|||
|
|
<span className={`text-sm ${
|
|||
|
|
orderData.items.length > 0 ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-tertiary)]'
|
|||
|
|
}`}>
|
|||
|
|
Productos agregados
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center">
|
|||
|
|
<div className={`w-3 h-3 rounded-full mr-3 ${
|
|||
|
|
orderData.payment_method ? 'bg-green-500' : 'bg-gray-300'
|
|||
|
|
}`} />
|
|||
|
|
<span className={`text-sm ${
|
|||
|
|
orderData.payment_method ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-tertiary)]'
|
|||
|
|
}`}>
|
|||
|
|
Método de pago
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Card>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Product Selection Modal */}
|
|||
|
|
<Modal
|
|||
|
|
isOpen={showProductModal}
|
|||
|
|
onClose={() => setShowProductModal(false)}
|
|||
|
|
title="Seleccionar Productos"
|
|||
|
|
size="lg"
|
|||
|
|
>
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* Product Search */}
|
|||
|
|
<div className="flex space-x-4">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<Input
|
|||
|
|
placeholder="Buscar productos..."
|
|||
|
|
value={productSearchTerm}
|
|||
|
|
onChange={(e) => setProductSearchTerm(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="w-48">
|
|||
|
|
<Select
|
|||
|
|
value={selectedCategory}
|
|||
|
|
onChange={setSelectedCategory}
|
|||
|
|
options={[
|
|||
|
|
{ value: '', label: 'Todas las categorías' },
|
|||
|
|
...categories.map(cat => ({ value: cat, label: cat }))
|
|||
|
|
]}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Products Grid */}
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 max-h-96 overflow-y-auto">
|
|||
|
|
{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>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)] mt-1">{product.description}</p>
|
|||
|
|
<div className="flex items-center mt-2">
|
|||
|
|
<Badge variant="soft" color="gray" className="mr-2">
|
|||
|
|
{product.category}
|
|||
|
|
</Badge>
|
|||
|
|
<span className="text-lg font-semibold text-[var(--color-info)]">
|
|||
|
|
€{product.price.toFixed(2)}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{product.allergens.length > 0 && (
|
|||
|
|
<div className="mt-2">
|
|||
|
|
<p className="text-xs text-[var(--color-primary)]">
|
|||
|
|
⚠️ Contiene: {product.allergens.join(', ')}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
onClick={() => addProductToOrder(product)}
|
|||
|
|
disabled={product.available_quantity === 0}
|
|||
|
|
>
|
|||
|
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|||
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|||
|
|
</svg>
|
|||
|
|
Agregar
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="text-xs text-[var(--text-tertiary)] mt-2">
|
|||
|
|
Stock: {product.available_quantity} | Preparación: {product.preparation_time}min
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{filteredProducts.length === 0 && (
|
|||
|
|
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
|||
|
|
<p>No se encontraron productos con los criterios seleccionados</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</Modal>
|
|||
|
|
|
|||
|
|
{/* Customer Selection Modal */}
|
|||
|
|
<Modal
|
|||
|
|
isOpen={showCustomerModal}
|
|||
|
|
onClose={() => setShowCustomerModal(false)}
|
|||
|
|
title="Seleccionar Cliente"
|
|||
|
|
size="lg"
|
|||
|
|
>
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* Customer Search */}
|
|||
|
|
<div className="flex space-x-3">
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<Input
|
|||
|
|
placeholder="Buscar por nombre, email o teléfono..."
|
|||
|
|
value={customerSearchTerm}
|
|||
|
|
onChange={(e) => setCustomerSearchTerm(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{allowCustomerCreation && (
|
|||
|
|
<Button onClick={() => setShowNewCustomerForm(true)}>
|
|||
|
|
Nuevo Cliente
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Customers List */}
|
|||
|
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|||
|
|
{customers
|
|||
|
|
.filter(customer =>
|
|||
|
|
!customerSearchTerm ||
|
|||
|
|
customer.name.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
|||
|
|
customer.email?.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
|||
|
|
customer.phone?.includes(customerSearchTerm)
|
|||
|
|
)
|
|||
|
|
.map(customer => (
|
|||
|
|
<div
|
|||
|
|
key={customer.id}
|
|||
|
|
className="border border-[var(--border-primary)] rounded-lg p-4 hover:bg-[var(--color-info)]/5 cursor-pointer"
|
|||
|
|
onClick={() => selectCustomer(customer)}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-start justify-between">
|
|||
|
|
<div>
|
|||
|
|
<h4 className="font-medium text-[var(--text-primary)]">{customer.name}</h4>
|
|||
|
|
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
|||
|
|
{customer.email && <div>📧 {customer.email}</div>}
|
|||
|
|
{customer.phone && <div>📞 {customer.phone}</div>}
|
|||
|
|
{customer.address && <div>📍 {customer.address}</div>}
|
|||
|
|
</div>
|
|||
|
|
<Badge variant="soft" color="gold" className="mt-2">
|
|||
|
|
{customer.loyalty_points} puntos
|
|||
|
|
</Badge>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{customers.filter(customer =>
|
|||
|
|
!customerSearchTerm ||
|
|||
|
|
customer.name.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
|||
|
|
customer.email?.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
|||
|
|
customer.phone?.includes(customerSearchTerm)
|
|||
|
|
).length === 0 && (
|
|||
|
|
<div className="text-center py-8 text-[var(--text-tertiary)]">
|
|||
|
|
<p>No se encontraron clientes</p>
|
|||
|
|
{allowCustomerCreation && customerSearchTerm && (
|
|||
|
|
<Button
|
|||
|
|
onClick={() => {
|
|||
|
|
setNewCustomerForm({ name: customerSearchTerm });
|
|||
|
|
setShowNewCustomerForm(true);
|
|||
|
|
}}
|
|||
|
|
className="mt-3"
|
|||
|
|
>
|
|||
|
|
Crear cliente "{customerSearchTerm}"
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</Modal>
|
|||
|
|
|
|||
|
|
{/* New Customer Form Modal */}
|
|||
|
|
<Modal
|
|||
|
|
isOpen={showNewCustomerForm}
|
|||
|
|
onClose={() => setShowNewCustomerForm(false)}
|
|||
|
|
title="Nuevo Cliente"
|
|||
|
|
>
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<Input
|
|||
|
|
label="Nombre *"
|
|||
|
|
value={newCustomerForm.name || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, name: e.target.value }))}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label="Teléfono *"
|
|||
|
|
value={newCustomerForm.phone || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, phone: e.target.value }))}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<Input
|
|||
|
|
label="Email"
|
|||
|
|
type="email"
|
|||
|
|
value={newCustomerForm.email || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, email: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<Input
|
|||
|
|
label="Dirección"
|
|||
|
|
value={newCustomerForm.address || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, address: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<Input
|
|||
|
|
label="Ciudad"
|
|||
|
|
value={newCustomerForm.city || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, city: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label="Código Postal"
|
|||
|
|
value={newCustomerForm.postal_code || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, postal_code: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Notas</label>
|
|||
|
|
<textarea
|
|||
|
|
value={newCustomerForm.notes || ''}
|
|||
|
|
onChange={(e) => setNewCustomerForm(prev => ({ ...prev, notes: e.target.value }))}
|
|||
|
|
rows={3}
|
|||
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
|||
|
|
placeholder="Alergias, preferencias, observaciones..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex justify-end space-x-3">
|
|||
|
|
<Button variant="outline" onClick={() => setShowNewCustomerForm(false)}>
|
|||
|
|
Cancelar
|
|||
|
|
</Button>
|
|||
|
|
<Button onClick={createNewCustomer}>
|
|||
|
|
Crear Cliente
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</Modal>
|
|||
|
|
|
|||
|
|
{/* Success/Error Messages */}
|
|||
|
|
{error && (
|
|||
|
|
<div className="fixed bottom-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg z-50">
|
|||
|
|
<div className="flex">
|
|||
|
|
<svg className="w-5 h-5 text-red-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|||
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|||
|
|
</svg>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm text-[var(--color-error)]">{error}</p>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setError(null)}
|
|||
|
|
className="mt-2"
|
|||
|
|
>
|
|||
|
|
Cerrar
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{success && (
|
|||
|
|
<div className="fixed bottom-4 right-4 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg z-50">
|
|||
|
|
<div className="flex">
|
|||
|
|
<svg className="w-5 h-5 text-green-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
|||
|
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
|||
|
|
</svg>
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm text-[var(--color-success)]">{success}</p>
|
|||
|
|
<Button
|
|||
|
|
size="sm"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setSuccess(null)}
|
|||
|
|
className="mt-2"
|
|||
|
|
>
|
|||
|
|
Cerrar
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export default OrderForm;
|