Add order page with real API calls
This commit is contained in:
@@ -25,6 +25,9 @@ class ApiClient {
|
|||||||
private tenantId: string | null = null;
|
private tenantId: string | null = null;
|
||||||
private refreshToken: string | null = null;
|
private refreshToken: string | null = null;
|
||||||
private isRefreshing: boolean = false;
|
private isRefreshing: boolean = false;
|
||||||
|
private refreshAttempts: number = 0;
|
||||||
|
private maxRefreshAttempts: number = 3;
|
||||||
|
private lastRefreshAttempt: number = 0;
|
||||||
private failedQueue: Array<{
|
private failedQueue: Array<{
|
||||||
resolve: (value?: any) => void;
|
resolve: (value?: any) => void;
|
||||||
reject: (error?: any) => void;
|
reject: (error?: any) => void;
|
||||||
@@ -72,6 +75,14 @@ class ApiClient {
|
|||||||
|
|
||||||
// Check if error is 401 and we have a refresh token
|
// Check if error is 401 and we have a refresh token
|
||||||
if (error.response?.status === 401 && this.refreshToken && !originalRequest._retry) {
|
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 (this.isRefreshing) {
|
||||||
// If already refreshing, queue this request
|
// If already refreshing, queue this request
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -81,8 +92,12 @@ class ApiClient {
|
|||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
this.isRefreshing = true;
|
this.isRefreshing = true;
|
||||||
|
this.refreshAttempts++;
|
||||||
|
this.lastRefreshAttempt = now;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`Attempting token refresh (attempt ${this.refreshAttempts})...`);
|
||||||
|
|
||||||
// Attempt to refresh the token
|
// Attempt to refresh the token
|
||||||
const response = await this.client.post('/auth/refresh', {
|
const response = await this.client.post('/auth/refresh', {
|
||||||
refresh_token: this.refreshToken
|
refresh_token: this.refreshToken
|
||||||
@@ -90,6 +105,11 @@ class ApiClient {
|
|||||||
|
|
||||||
const { access_token, refresh_token } = response.data;
|
const { access_token, refresh_token } = response.data;
|
||||||
|
|
||||||
|
console.log('Token refresh successful');
|
||||||
|
|
||||||
|
// Reset refresh attempts on success
|
||||||
|
this.refreshAttempts = 0;
|
||||||
|
|
||||||
// Update tokens
|
// Update tokens
|
||||||
this.setAuthToken(access_token);
|
this.setAuthToken(access_token);
|
||||||
if (refresh_token) {
|
if (refresh_token) {
|
||||||
@@ -107,6 +127,7 @@ class ApiClient {
|
|||||||
return this.client(originalRequest);
|
return this.client(originalRequest);
|
||||||
|
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
console.error(`Token refresh failed (attempt ${this.refreshAttempts}):`, refreshError);
|
||||||
// Refresh failed, clear tokens and redirect to login
|
// Refresh failed, clear tokens and redirect to login
|
||||||
this.processQueue(refreshError, null);
|
this.processQueue(refreshError, null);
|
||||||
await this.handleAuthFailure();
|
await this.handleAuthFailure();
|
||||||
@@ -165,13 +186,14 @@ class ApiClient {
|
|||||||
try {
|
try {
|
||||||
// Dynamically import to avoid circular dependency
|
// Dynamically import to avoid circular dependency
|
||||||
const { useAuthStore } = await import('../../stores/auth.store');
|
const { useAuthStore } = await import('../../stores/auth.store');
|
||||||
const store = useAuthStore.getState();
|
const setState = useAuthStore.setState;
|
||||||
|
|
||||||
// Update the store with new tokens
|
// Update the store with new tokens
|
||||||
store.token = accessToken;
|
setState(state => ({
|
||||||
if (refreshToken) {
|
...state,
|
||||||
store.refreshToken = refreshToken;
|
token: accessToken,
|
||||||
}
|
refreshToken: refreshToken || state.refreshToken,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to update auth store:', 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
|
* Based on backend schemas in services/orders/app/schemas/order_schemas.py
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type CustomerType = 'individual' | 'business' | 'central_bakery';
|
export enum CustomerType {
|
||||||
export type DeliveryMethod = 'delivery' | 'pickup';
|
INDIVIDUAL = 'individual',
|
||||||
export type PaymentTerms = 'immediate' | 'net_30' | 'net_60';
|
BUSINESS = 'business',
|
||||||
export type PaymentMethod = 'cash' | 'card' | 'bank_transfer' | 'account';
|
CENTRAL_BAKERY = 'central_bakery'
|
||||||
export type PaymentStatus = 'pending' | 'partial' | 'paid' | 'failed' | 'refunded';
|
}
|
||||||
export type CustomerSegment = 'vip' | 'regular' | 'wholesale';
|
|
||||||
export type PriorityLevel = 'high' | 'normal' | 'low';
|
export enum DeliveryMethod {
|
||||||
export type OrderType = 'standard' | 'rush' | 'recurring' | 'special';
|
DELIVERY = 'delivery',
|
||||||
export type OrderStatus = 'pending' | 'confirmed' | 'in_production' | 'ready' | 'out_for_delivery' | 'delivered' | 'cancelled' | 'failed';
|
PICKUP = 'pickup'
|
||||||
export type OrderSource = 'manual' | 'online' | 'phone' | 'app' | 'api';
|
}
|
||||||
export type SalesChannel = 'direct' | 'wholesale' | 'retail';
|
|
||||||
export type BusinessModel = 'individual_bakery' | 'central_bakery';
|
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 =====
|
// ===== 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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`
|
className={`
|
||||||
p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
p-4 sm:p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||||
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.02]' : ''}
|
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.01]' : ''}
|
||||||
${statusIndicator.isCritical
|
${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
|
: statusIndicator.isHighlight
|
||||||
? 'ring-1 ring-yellow-200'
|
? 'ring-1 ring-yellow-200 border-l-6'
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
${className}
|
${className}
|
||||||
@@ -130,30 +130,30 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
|||||||
}}
|
}}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-4 sm:space-y-5">
|
||||||
{/* Header with status indicator */}
|
{/* Header with status indicator */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4 flex-1">
|
<div className="flex items-start gap-4 flex-1">
|
||||||
<div
|
<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' : ''
|
statusIndicator.isCritical ? 'ring-2 ring-white' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: `${statusIndicator.color}20` }}
|
style={{ backgroundColor: `${statusIndicator.color}20` }}
|
||||||
>
|
>
|
||||||
{StatusIcon && (
|
{StatusIcon && (
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
className="w-5 h-5"
|
className="w-4 h-4 sm:w-5 sm:h-5"
|
||||||
style={{ color: statusIndicator.color }}
|
style={{ color: statusIndicator.color }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div
|
<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
|
statusIndicator.isCritical
|
||||||
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
|
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
|
||||||
: statusIndicator.isHighlight
|
: statusIndicator.isHighlight
|
||||||
@@ -186,25 +186,25 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right flex-shrink-0 ml-4">
|
<div className="text-right flex-shrink-0 ml-4 min-w-0">
|
||||||
<div className="text-3xl font-bold text-[var(--text-primary)] leading-none">
|
<div className="text-2xl sm:text-3xl font-bold text-[var(--text-primary)] leading-none truncate">
|
||||||
{primaryValue}
|
{primaryValue}
|
||||||
</div>
|
</div>
|
||||||
{primaryValueLabel && (
|
{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}
|
{primaryValueLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secondary info */}
|
{/* Secondary info - Mobile optimized */}
|
||||||
{secondaryInfo && (
|
{secondaryInfo && (
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm gap-2">
|
||||||
<span className="text-[var(--text-secondary)]">
|
<span className="text-[var(--text-secondary)] truncate flex-shrink-0">
|
||||||
{secondaryInfo.label}
|
{secondaryInfo.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-[var(--text-primary)]">
|
<span className="font-medium text-[var(--text-primary)] truncate text-right">
|
||||||
{secondaryInfo.value}
|
{secondaryInfo.value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,36 +228,36 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata - Improved mobile layout */}
|
||||||
{metadata.length > 0 && (
|
{metadata.length > 0 && (
|
||||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||||
{metadata.map((item, index) => (
|
{metadata.map((item, index) => (
|
||||||
<div key={index}>{item}</div>
|
<div key={index} className="truncate" title={item}>{item}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Simplified Action System */}
|
{/* Simplified Action System - Mobile optimized */}
|
||||||
{actions.length > 0 && (
|
{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 */}
|
{/* 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 */}
|
{/* Primary action as a subtle text button */}
|
||||||
{primaryActions.length > 0 && (
|
{primaryActions.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={primaryActions[0].onClick}
|
onClick={primaryActions[0].onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg
|
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
|
transition-all duration-200 hover:scale-105 active:scale-95 flex-shrink-0
|
||||||
${primaryActions[0].destructive
|
${primaryActions[0].destructive
|
||||||
? 'text-red-600 hover:bg-red-50 hover:text-red-700'
|
? '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)]'
|
: '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" })}
|
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-3 h-3 sm:w-4 sm:h-4" })}
|
||||||
<span>{primaryActions[0].label}</span>
|
<span className="truncate">{primaryActions[0].label}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -269,14 +269,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
|||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
title={action.label}
|
title={action.label}
|
||||||
className={`
|
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
|
${action.destructive
|
||||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
? '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)]'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -287,14 +287,14 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
|||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
title={action.label}
|
title={action.label}
|
||||||
className={`
|
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
|
${action.destructive
|
||||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
? '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)]'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 { useAuthStore, User } from '../stores/auth.store';
|
||||||
|
import { authService } from '../api/services/auth';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -31,29 +32,43 @@ interface AuthProviderProps {
|
|||||||
|
|
||||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
||||||
// Initialize auth on mount
|
// Initialize auth on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeAuth = async () => {
|
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
|
// Check if we have stored auth data
|
||||||
const storedAuth = localStorage.getItem('auth-storage');
|
if (authStore.token && authStore.refreshToken) {
|
||||||
if (storedAuth) {
|
|
||||||
try {
|
try {
|
||||||
const { state } = JSON.parse(storedAuth);
|
// Validate current token by trying to verify it
|
||||||
if (state.token && state.user) {
|
await authService.verifyToken();
|
||||||
// Validate token by attempting to refresh
|
console.log('Token is valid, user authenticated');
|
||||||
try {
|
|
||||||
await authStore.refreshAuth();
|
|
||||||
} catch (error) {
|
|
||||||
// Token is invalid, clear auth
|
|
||||||
authStore.logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing stored auth:', error);
|
console.log('Token expired, attempting refresh...');
|
||||||
authStore.logout();
|
// 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();
|
initializeAuth();
|
||||||
@@ -63,7 +78,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
const contextValue: AuthContextType = {
|
const contextValue: AuthContextType = {
|
||||||
user: authStore.user,
|
user: authStore.user,
|
||||||
isAuthenticated: authStore.isAuthenticated,
|
isAuthenticated: authStore.isAuthenticated,
|
||||||
isLoading: authStore.isLoading,
|
isLoading: authStore.isLoading || isInitializing,
|
||||||
error: authStore.error,
|
error: authStore.error,
|
||||||
login: authStore.login,
|
login: authStore.login,
|
||||||
logout: authStore.logout,
|
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 inventoryEs from './es/inventory.json';
|
||||||
import foodSafetyEs from './es/foodSafety.json';
|
import foodSafetyEs from './es/foodSafety.json';
|
||||||
import suppliersEs from './es/suppliers.json';
|
import suppliersEs from './es/suppliers.json';
|
||||||
|
import ordersEs from './es/orders.json';
|
||||||
import errorsEs from './es/errors.json';
|
import errorsEs from './es/errors.json';
|
||||||
|
|
||||||
// Translation resources by language
|
// Translation resources by language
|
||||||
@@ -14,6 +15,7 @@ export const resources = {
|
|||||||
inventory: inventoryEs,
|
inventory: inventoryEs,
|
||||||
foodSafety: foodSafetyEs,
|
foodSafety: foodSafetyEs,
|
||||||
suppliers: suppliersEs,
|
suppliers: suppliersEs,
|
||||||
|
orders: ordersEs,
|
||||||
errors: errorsEs,
|
errors: errorsEs,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -37,7 +39,7 @@ export const languageConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Namespaces available in translations
|
// 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];
|
export type Namespace = typeof namespaces[number];
|
||||||
|
|
||||||
// Helper function to get language display name
|
// 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 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
|
// Default export with all translations
|
||||||
export default resources;
|
export default resources;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
@@ -410,17 +410,22 @@ const ProcurementPage: React.FC = () => {
|
|||||||
id={plan.plan_number}
|
id={plan.plan_number}
|
||||||
statusIndicator={statusConfig}
|
statusIndicator={statusConfig}
|
||||||
title={`Plan ${plan.plan_number}`}
|
title={`Plan ${plan.plan_number}`}
|
||||||
subtitle={new Date(plan.plan_date).toLocaleDateString('es-ES')}
|
subtitle={`${new Date(plan.plan_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' })} • ${plan.procurement_strategy}`}
|
||||||
primaryValue={formatters.currency(plan.total_estimated_cost)}
|
primaryValue={plan.total_requirements}
|
||||||
primaryValueLabel={`${plan.total_requirements} requerimientos`}
|
primaryValueLabel="requerimientos"
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Período',
|
label: 'Presupuesto',
|
||||||
value: `${new Date(plan.plan_period_start).toLocaleDateString('es-ES')} - ${new Date(plan.plan_period_end).toLocaleDateString('es-ES')}`
|
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={[
|
metadata={[
|
||||||
`${plan.planning_horizon_days} días de horizonte`,
|
`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' })}`,
|
||||||
`Estrategia: ${plan.procurement_strategy}`,
|
`Creado: ${new Date(plan.created_at).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' })}`,
|
||||||
...(plan.special_requirements ? [`"${plan.special_requirements}"`] : [])
|
...(plan.special_requirements ? [`Req. especiales: ${plan.special_requirements}`] : [])
|
||||||
]}
|
]}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
@@ -541,24 +546,43 @@ const ProcurementPage: React.FC = () => {
|
|||||||
isCritical: true
|
isCritical: true
|
||||||
}}
|
}}
|
||||||
title={requirement.product_name}
|
title={requirement.product_name}
|
||||||
subtitle={requirement.requirement_number}
|
subtitle={`${requirement.requirement_number} • ${requirement.supplier_name || 'Sin proveedor'}`}
|
||||||
primaryValue={`${requirement.required_quantity} ${requirement.unit_of_measure}`}
|
primaryValue={requirement.required_quantity}
|
||||||
primaryValueLabel="Cantidad requerida"
|
primaryValueLabel={requirement.unit_of_measure}
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Fecha límite',
|
label: 'Límite',
|
||||||
value: new Date(requirement.required_by_date).toLocaleDateString('es-ES')
|
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={[
|
metadata={[
|
||||||
`Stock actual: ${requirement.current_stock_level} ${requirement.unit_of_measure}`,
|
`Stock: ${requirement.current_stock_level || 0} ${requirement.unit_of_measure}`,
|
||||||
`Proveedor: ${requirement.supplier_name || 'No asignado'}`,
|
`Necesario: ${requirement.required_quantity - (requirement.current_stock_level || 0)} ${requirement.unit_of_measure}`,
|
||||||
`Costo estimado: ${formatters.currency(requirement.estimated_total_cost || 0)}`
|
`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={[
|
actions={[
|
||||||
{
|
{
|
||||||
label: 'Ver Detalles',
|
label: 'Ver Detalles',
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
variant: 'outline',
|
variant: 'primary',
|
||||||
|
priority: 'primary',
|
||||||
onClick: () => console.log('View requirement details')
|
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}
|
statusIndicator={statusConfig}
|
||||||
title={recipe.name}
|
title={recipe.name}
|
||||||
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
|
subtitle={`${statusConfig.text} • ${statusConfig.difficultyLabel}${statusConfig.isHighlight ? ' ★' + recipe.rating : ''}`}
|
||||||
primaryValue={formatters.currency(recipe.profit)}
|
primaryValue={recipe.ingredients.length}
|
||||||
primaryValueLabel="margen"
|
primaryValueLabel="ingredientes"
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Precio de venta',
|
label: 'Margen',
|
||||||
value: `${formatters.currency(recipe.price)} (costo: ${formatters.currency(recipe.cost)})`
|
value: `€${formatters.compact(recipe.profit)}`
|
||||||
}}
|
}}
|
||||||
progress={{
|
progress={{
|
||||||
label: 'Margen de beneficio',
|
label: 'Margen de beneficio',
|
||||||
|
|||||||
@@ -216,12 +216,12 @@ const SuppliersPage: React.FC = () => {
|
|||||||
id={supplier.id}
|
id={supplier.id}
|
||||||
statusIndicator={statusConfig}
|
statusIndicator={statusConfig}
|
||||||
title={supplier.name}
|
title={supplier.name}
|
||||||
subtitle={supplier.supplier_code}
|
subtitle={`${getSupplierTypeText(supplier.supplier_type)} • ${supplier.city || 'Sin ubicación'}`}
|
||||||
primaryValue={supplier.city || 'Sin ubicación'}
|
primaryValue={supplier.standard_lead_time || 0}
|
||||||
primaryValueLabel={getSupplierTypeText(supplier.supplier_type)}
|
primaryValueLabel="días"
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Condiciones',
|
label: 'Pedido Min.',
|
||||||
value: getPaymentTermsText(supplier.payment_terms)
|
value: `€${formatters.compact(supplier.minimum_order_amount || 0)}`
|
||||||
}}
|
}}
|
||||||
metadata={[
|
metadata={[
|
||||||
supplier.contact_person || 'Sin contacto',
|
supplier.contact_person || 'Sin contacto',
|
||||||
|
|||||||
@@ -377,11 +377,11 @@ const TeamPage: React.FC = () => {
|
|||||||
statusIndicator={getMemberStatusConfig(member)}
|
statusIndicator={getMemberStatusConfig(member)}
|
||||||
title={member.user?.full_name || member.user_full_name}
|
title={member.user?.full_name || member.user_full_name}
|
||||||
subtitle={member.user?.email || member.user_email}
|
subtitle={member.user?.email || member.user_email}
|
||||||
primaryValue={member.is_active ? 'Activo' : 'Inactivo'}
|
primaryValue={Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24))}
|
||||||
primaryValueLabel="Estado"
|
primaryValueLabel="días"
|
||||||
secondaryInfo={{
|
secondaryInfo={{
|
||||||
label: 'Se unió',
|
label: 'Estado',
|
||||||
value: new Date(member.joined_at).toLocaleDateString('es-ES')
|
value: member.is_active ? 'Activo' : 'Inactivo'
|
||||||
}}
|
}}
|
||||||
metadata={[
|
metadata={[
|
||||||
`Email: ${member.user?.email || member.user_email}`,
|
`Email: ${member.user?.email || member.user_email}`,
|
||||||
|
|||||||
@@ -256,16 +256,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
// Initialize API client with stored tokens when store rehydrates
|
// Initialize API client with stored tokens when store rehydrates
|
||||||
if (state?.token) {
|
if (state?.token) {
|
||||||
import('../api').then(({ apiClient }) => {
|
// Use direct import to avoid timing issues
|
||||||
apiClient.setAuthToken(state.token!);
|
apiClient.setAuthToken(state.token);
|
||||||
if (state.refreshToken) {
|
if (state.refreshToken) {
|
||||||
apiClient.setRefreshToken(state.refreshToken);
|
apiClient.setRefreshToken(state.refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.user?.tenant_id) {
|
if (state.user?.tenant_id) {
|
||||||
apiClient.setTenantId(state.user.tenant_id);
|
apiClient.setTenantId(state.user.tenant_id);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ import {
|
|||||||
type EnumOption
|
type EnumOption
|
||||||
} from '../api/types/suppliers';
|
} 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
|
* Generic function to convert enum to select options with i18n translations
|
||||||
*/
|
*/
|
||||||
@@ -164,3 +178,131 @@ export function isValidEnumValue<T>(
|
|||||||
): value is T {
|
): value is T {
|
||||||
return Object.values(enumObject).includes(value as 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}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
27
scripts/seed_orders_test_data.sh
Executable file
27
scripts/seed_orders_test_data.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to seed the orders database with test data
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🌱 Seeding Orders Database with Test Data"
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
# Change to the orders service directory
|
||||||
|
cd services/orders
|
||||||
|
|
||||||
|
# Make sure we're in a virtual environment or have the dependencies
|
||||||
|
echo "📦 Setting up environment..."
|
||||||
|
|
||||||
|
# Run the seeding script
|
||||||
|
echo "🚀 Running seeding script..."
|
||||||
|
python scripts/seed_test_data.py
|
||||||
|
|
||||||
|
echo "✅ Database seeding completed!"
|
||||||
|
echo ""
|
||||||
|
echo "🎯 Test data created:"
|
||||||
|
echo " - 6 customers (including VIP, wholesale, and inactive)"
|
||||||
|
echo " - 25 orders in various statuses"
|
||||||
|
echo " - Order items with different products"
|
||||||
|
echo " - Order status history"
|
||||||
|
echo ""
|
||||||
|
echo "📋 You can now test the frontend with real data!"
|
||||||
@@ -55,8 +55,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService:
|
|||||||
status_history_repo=OrderStatusHistoryRepository(),
|
status_history_repo=OrderStatusHistoryRepository(),
|
||||||
inventory_client=get_inventory_client(),
|
inventory_client=get_inventory_client(),
|
||||||
production_client=get_production_client(),
|
production_client=get_production_client(),
|
||||||
sales_client=get_sales_client(),
|
sales_client=get_sales_client()
|
||||||
notification_client=None # Notification client not available
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
152
services/orders/app/models/enums.py
Normal file
152
services/orders/app/models/enums.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# services/orders/app/models/enums.py
|
||||||
|
"""
|
||||||
|
Enum definitions for Orders Service
|
||||||
|
Following the pattern used in the Inventory Service for better type safety and maintainability
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerType(enum.Enum):
|
||||||
|
"""Customer type classifications"""
|
||||||
|
INDIVIDUAL = "individual"
|
||||||
|
BUSINESS = "business"
|
||||||
|
CENTRAL_BAKERY = "central_bakery"
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryMethod(enum.Enum):
|
||||||
|
"""Order delivery methods"""
|
||||||
|
DELIVERY = "delivery"
|
||||||
|
PICKUP = "pickup"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentTerms(enum.Enum):
|
||||||
|
"""Payment terms for customers and orders"""
|
||||||
|
IMMEDIATE = "immediate"
|
||||||
|
NET_30 = "net_30"
|
||||||
|
NET_60 = "net_60"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(enum.Enum):
|
||||||
|
"""Payment methods for orders"""
|
||||||
|
CASH = "cash"
|
||||||
|
CARD = "card"
|
||||||
|
BANK_TRANSFER = "bank_transfer"
|
||||||
|
ACCOUNT = "account"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentStatus(enum.Enum):
|
||||||
|
"""Payment status for orders"""
|
||||||
|
PENDING = "pending"
|
||||||
|
PARTIAL = "partial"
|
||||||
|
PAID = "paid"
|
||||||
|
FAILED = "failed"
|
||||||
|
REFUNDED = "refunded"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerSegment(enum.Enum):
|
||||||
|
"""Customer segmentation categories"""
|
||||||
|
VIP = "vip"
|
||||||
|
REGULAR = "regular"
|
||||||
|
WHOLESALE = "wholesale"
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityLevel(enum.Enum):
|
||||||
|
"""Priority levels for orders and customers"""
|
||||||
|
HIGH = "high"
|
||||||
|
NORMAL = "normal"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderType(enum.Enum):
|
||||||
|
"""Order type classifications"""
|
||||||
|
STANDARD = "standard"
|
||||||
|
RUSH = "rush"
|
||||||
|
RECURRING = "recurring"
|
||||||
|
SPECIAL = "special"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(enum.Enum):
|
||||||
|
"""Order status workflow"""
|
||||||
|
PENDING = "pending"
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
IN_PRODUCTION = "in_production"
|
||||||
|
READY = "ready"
|
||||||
|
OUT_FOR_DELIVERY = "out_for_delivery"
|
||||||
|
DELIVERED = "delivered"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class OrderSource(enum.Enum):
|
||||||
|
"""Source of order creation"""
|
||||||
|
MANUAL = "manual"
|
||||||
|
ONLINE = "online"
|
||||||
|
PHONE = "phone"
|
||||||
|
APP = "app"
|
||||||
|
API = "api"
|
||||||
|
|
||||||
|
|
||||||
|
class SalesChannel(enum.Enum):
|
||||||
|
"""Sales channel classification"""
|
||||||
|
DIRECT = "direct"
|
||||||
|
WHOLESALE = "wholesale"
|
||||||
|
RETAIL = "retail"
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessModel(enum.Enum):
|
||||||
|
"""Business model types"""
|
||||||
|
INDIVIDUAL_BAKERY = "individual_bakery"
|
||||||
|
CENTRAL_BAKERY = "central_bakery"
|
||||||
|
|
||||||
|
|
||||||
|
# Procurement-related enums
|
||||||
|
class ProcurementPlanType(enum.Enum):
|
||||||
|
"""Procurement plan types"""
|
||||||
|
REGULAR = "regular"
|
||||||
|
EMERGENCY = "emergency"
|
||||||
|
SEASONAL = "seasonal"
|
||||||
|
|
||||||
|
|
||||||
|
class ProcurementStrategy(enum.Enum):
|
||||||
|
"""Procurement strategies"""
|
||||||
|
JUST_IN_TIME = "just_in_time"
|
||||||
|
BULK = "bulk"
|
||||||
|
MIXED = "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(enum.Enum):
|
||||||
|
"""Risk level classifications"""
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementStatus(enum.Enum):
|
||||||
|
"""Procurement requirement status"""
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
ORDERED = "ordered"
|
||||||
|
PARTIALLY_RECEIVED = "partially_received"
|
||||||
|
RECEIVED = "received"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanStatus(enum.Enum):
|
||||||
|
"""Procurement plan status"""
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING_APPROVAL = "pending_approval"
|
||||||
|
APPROVED = "approved"
|
||||||
|
IN_EXECUTION = "in_execution"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryStatus(enum.Enum):
|
||||||
|
"""Delivery status for procurement"""
|
||||||
|
PENDING = "pending"
|
||||||
|
IN_TRANSIT = "in_transit"
|
||||||
|
DELIVERED = "delivered"
|
||||||
|
DELAYED = "delayed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
Order-related repositories for Orders Service
|
Order-related repositories for Orders Service
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@@ -98,6 +98,29 @@ class CustomerRepository(BaseRepository[Customer, dict, dict]):
|
|||||||
error=str(e))
|
error=str(e))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def count_created_since(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: UUID,
|
||||||
|
since_date: datetime
|
||||||
|
) -> int:
|
||||||
|
"""Count customers created since a specific date"""
|
||||||
|
try:
|
||||||
|
query = select(func.count()).select_from(Customer).where(
|
||||||
|
and_(
|
||||||
|
Customer.tenant_id == tenant_id,
|
||||||
|
Customer.created_at >= since_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error counting customers created since date",
|
||||||
|
tenant_id=str(tenant_id),
|
||||||
|
since_date=str(since_date),
|
||||||
|
error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
||||||
"""Repository for customer order operations"""
|
"""Repository for customer order operations"""
|
||||||
@@ -105,6 +128,57 @@ class OrderRepository(BaseRepository[CustomerOrder, OrderCreate, OrderUpdate]):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(CustomerOrder)
|
super().__init__(CustomerOrder)
|
||||||
|
|
||||||
|
async def get_multi(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
tenant_id: Optional[UUID] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
filters: Optional[Dict[str, Any]] = None,
|
||||||
|
order_by: Optional[str] = None,
|
||||||
|
order_desc: bool = False
|
||||||
|
) -> List[CustomerOrder]:
|
||||||
|
"""Get multiple orders with eager loading of items and customer"""
|
||||||
|
try:
|
||||||
|
query = select(self.model).options(
|
||||||
|
selectinload(CustomerOrder.items),
|
||||||
|
selectinload(CustomerOrder.customer)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply tenant filter
|
||||||
|
if tenant_id:
|
||||||
|
query = query.where(self.model.tenant_id == tenant_id)
|
||||||
|
|
||||||
|
# Apply additional filters
|
||||||
|
if filters:
|
||||||
|
for key, value in filters.items():
|
||||||
|
if hasattr(self.model, key) and value is not None:
|
||||||
|
field = getattr(self.model, key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
query = query.where(field.in_(value))
|
||||||
|
else:
|
||||||
|
query = query.where(field == value)
|
||||||
|
|
||||||
|
# Apply ordering
|
||||||
|
if order_by and hasattr(self.model, order_by):
|
||||||
|
order_column = getattr(self.model, order_by)
|
||||||
|
if order_desc:
|
||||||
|
query = query.order_by(order_column.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(order_column)
|
||||||
|
else:
|
||||||
|
# Default ordering by order_date desc
|
||||||
|
query = query.order_by(CustomerOrder.order_date.desc())
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalars().all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error getting multiple orders", error=str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_with_items(
|
async def get_with_items(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
|
|||||||
@@ -11,13 +11,20 @@ from typing import Optional, List, Dict, Any
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
from app.models.enums import (
|
||||||
|
CustomerType, DeliveryMethod, PaymentTerms, PaymentMethod, PaymentStatus,
|
||||||
|
CustomerSegment, PriorityLevel, OrderType, OrderStatus, OrderSource,
|
||||||
|
SalesChannel, BusinessModel, ProcurementPlanType, ProcurementStrategy,
|
||||||
|
RiskLevel, RequirementStatus, PlanStatus, DeliveryStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ===== Customer Schemas =====
|
# ===== Customer Schemas =====
|
||||||
|
|
||||||
class CustomerBase(BaseModel):
|
class CustomerBase(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=200)
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
business_name: Optional[str] = Field(None, max_length=200)
|
business_name: Optional[str] = Field(None, max_length=200)
|
||||||
customer_type: str = Field(default="individual", pattern="^(individual|business|central_bakery)$")
|
customer_type: CustomerType = Field(default=CustomerType.INDIVIDUAL)
|
||||||
email: Optional[str] = Field(None, max_length=255)
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
phone: Optional[str] = Field(None, max_length=50)
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
address_line1: Optional[str] = Field(None, max_length=255)
|
address_line1: Optional[str] = Field(None, max_length=255)
|
||||||
@@ -27,16 +34,20 @@ class CustomerBase(BaseModel):
|
|||||||
postal_code: Optional[str] = Field(None, max_length=20)
|
postal_code: Optional[str] = Field(None, max_length=20)
|
||||||
country: str = Field(default="US", max_length=100)
|
country: str = Field(default="US", max_length=100)
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
preferred_delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$")
|
preferred_delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
|
||||||
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
|
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
|
||||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||||
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
|
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
|
||||||
customer_segment: str = Field(default="regular", pattern="^(vip|regular|wholesale)$")
|
customer_segment: CustomerSegment = Field(default=CustomerSegment.REGULAR)
|
||||||
priority_level: str = Field(default="normal", pattern="^(high|normal|low)$")
|
priority_level: PriorityLevel = Field(default=PriorityLevel.NORMAL)
|
||||||
special_instructions: Optional[str] = None
|
special_instructions: Optional[str] = None
|
||||||
delivery_preferences: Optional[Dict[str, Any]] = None
|
delivery_preferences: Optional[Dict[str, Any]] = None
|
||||||
product_preferences: Optional[Dict[str, Any]] = None
|
product_preferences: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class CustomerCreate(CustomerBase):
|
class CustomerCreate(CustomerBase):
|
||||||
customer_code: str = Field(..., min_length=1, max_length=50)
|
customer_code: str = Field(..., min_length=1, max_length=50)
|
||||||
@@ -46,7 +57,7 @@ class CustomerCreate(CustomerBase):
|
|||||||
class CustomerUpdate(BaseModel):
|
class CustomerUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||||
business_name: Optional[str] = Field(None, max_length=200)
|
business_name: Optional[str] = Field(None, max_length=200)
|
||||||
customer_type: Optional[str] = Field(None, pattern="^(individual|business|central_bakery)$")
|
customer_type: Optional[CustomerType] = None
|
||||||
email: Optional[str] = Field(None, max_length=255)
|
email: Optional[str] = Field(None, max_length=255)
|
||||||
phone: Optional[str] = Field(None, max_length=50)
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
address_line1: Optional[str] = Field(None, max_length=255)
|
address_line1: Optional[str] = Field(None, max_length=255)
|
||||||
@@ -56,16 +67,20 @@ class CustomerUpdate(BaseModel):
|
|||||||
postal_code: Optional[str] = Field(None, max_length=20)
|
postal_code: Optional[str] = Field(None, max_length=20)
|
||||||
country: Optional[str] = Field(None, max_length=100)
|
country: Optional[str] = Field(None, max_length=100)
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
preferred_delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$")
|
preferred_delivery_method: Optional[DeliveryMethod] = None
|
||||||
payment_terms: Optional[str] = Field(None, pattern="^(immediate|net_30|net_60)$")
|
payment_terms: Optional[PaymentTerms] = None
|
||||||
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
credit_limit: Optional[Decimal] = Field(None, ge=0)
|
||||||
discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
|
discount_percentage: Optional[Decimal] = Field(None, ge=0, le=100)
|
||||||
customer_segment: Optional[str] = Field(None, pattern="^(vip|regular|wholesale)$")
|
customer_segment: Optional[CustomerSegment] = None
|
||||||
priority_level: Optional[str] = Field(None, pattern="^(high|normal|low)$")
|
priority_level: Optional[PriorityLevel] = None
|
||||||
special_instructions: Optional[str] = None
|
special_instructions: Optional[str] = None
|
||||||
delivery_preferences: Optional[Dict[str, Any]] = None
|
delivery_preferences: Optional[Dict[str, Any]] = None
|
||||||
product_preferences: Optional[Dict[str, Any]] = None
|
product_preferences: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class CustomerResponse(CustomerBase):
|
class CustomerResponse(CustomerBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -129,26 +144,30 @@ class OrderItemResponse(OrderItemBase):
|
|||||||
|
|
||||||
class OrderBase(BaseModel):
|
class OrderBase(BaseModel):
|
||||||
customer_id: UUID
|
customer_id: UUID
|
||||||
order_type: str = Field(default="standard", pattern="^(standard|rush|recurring|special)$")
|
order_type: OrderType = Field(default=OrderType.STANDARD)
|
||||||
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
|
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
|
||||||
requested_delivery_date: datetime
|
requested_delivery_date: datetime
|
||||||
delivery_method: str = Field(default="delivery", pattern="^(delivery|pickup)$")
|
delivery_method: DeliveryMethod = Field(default=DeliveryMethod.DELIVERY)
|
||||||
delivery_address: Optional[Dict[str, Any]] = None
|
delivery_address: Optional[Dict[str, Any]] = None
|
||||||
delivery_instructions: Optional[str] = None
|
delivery_instructions: Optional[str] = None
|
||||||
delivery_window_start: Optional[datetime] = None
|
delivery_window_start: Optional[datetime] = None
|
||||||
delivery_window_end: Optional[datetime] = None
|
delivery_window_end: Optional[datetime] = None
|
||||||
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
|
discount_percentage: Decimal = Field(default=Decimal("0.00"), ge=0, le=100)
|
||||||
delivery_fee: Decimal = Field(default=Decimal("0.00"), ge=0)
|
delivery_fee: Decimal = Field(default=Decimal("0.00"), ge=0)
|
||||||
payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
|
payment_method: Optional[PaymentMethod] = None
|
||||||
payment_terms: str = Field(default="immediate", pattern="^(immediate|net_30|net_60)$")
|
payment_terms: PaymentTerms = Field(default=PaymentTerms.IMMEDIATE)
|
||||||
special_instructions: Optional[str] = None
|
special_instructions: Optional[str] = None
|
||||||
custom_requirements: Optional[Dict[str, Any]] = None
|
custom_requirements: Optional[Dict[str, Any]] = None
|
||||||
allergen_warnings: Optional[Dict[str, Any]] = None
|
allergen_warnings: Optional[Dict[str, Any]] = None
|
||||||
order_source: str = Field(default="manual", pattern="^(manual|online|phone|app|api)$")
|
order_source: OrderSource = Field(default=OrderSource.MANUAL)
|
||||||
sales_channel: str = Field(default="direct", pattern="^(direct|wholesale|retail)$")
|
sales_channel: SalesChannel = Field(default=SalesChannel.DIRECT)
|
||||||
order_origin: Optional[str] = Field(None, max_length=100)
|
order_origin: Optional[str] = Field(None, max_length=100)
|
||||||
communication_preferences: Optional[Dict[str, Any]] = None
|
communication_preferences: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class OrderCreate(OrderBase):
|
class OrderCreate(OrderBase):
|
||||||
tenant_id: UUID
|
tenant_id: UUID
|
||||||
@@ -156,21 +175,25 @@ class OrderCreate(OrderBase):
|
|||||||
|
|
||||||
|
|
||||||
class OrderUpdate(BaseModel):
|
class OrderUpdate(BaseModel):
|
||||||
status: Optional[str] = Field(None, pattern="^(pending|confirmed|in_production|ready|out_for_delivery|delivered|cancelled|failed)$")
|
status: Optional[OrderStatus] = None
|
||||||
priority: Optional[str] = Field(None, pattern="^(high|normal|low)$")
|
priority: Optional[PriorityLevel] = None
|
||||||
requested_delivery_date: Optional[datetime] = None
|
requested_delivery_date: Optional[datetime] = None
|
||||||
confirmed_delivery_date: Optional[datetime] = None
|
confirmed_delivery_date: Optional[datetime] = None
|
||||||
delivery_method: Optional[str] = Field(None, pattern="^(delivery|pickup)$")
|
delivery_method: Optional[DeliveryMethod] = None
|
||||||
delivery_address: Optional[Dict[str, Any]] = None
|
delivery_address: Optional[Dict[str, Any]] = None
|
||||||
delivery_instructions: Optional[str] = None
|
delivery_instructions: Optional[str] = None
|
||||||
delivery_window_start: Optional[datetime] = None
|
delivery_window_start: Optional[datetime] = None
|
||||||
delivery_window_end: Optional[datetime] = None
|
delivery_window_end: Optional[datetime] = None
|
||||||
payment_method: Optional[str] = Field(None, pattern="^(cash|card|bank_transfer|account)$")
|
payment_method: Optional[PaymentMethod] = None
|
||||||
payment_status: Optional[str] = Field(None, pattern="^(pending|partial|paid|failed|refunded)$")
|
payment_status: Optional[PaymentStatus] = None
|
||||||
special_instructions: Optional[str] = None
|
special_instructions: Optional[str] = None
|
||||||
custom_requirements: Optional[Dict[str, Any]] = None
|
custom_requirements: Optional[Dict[str, Any]] = None
|
||||||
allergen_warnings: Optional[Dict[str, Any]] = None
|
allergen_warnings: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class OrderResponse(OrderBase):
|
class OrderResponse(OrderBase):
|
||||||
id: UUID
|
id: UUID
|
||||||
@@ -205,17 +228,21 @@ class ProcurementRequirementBase(BaseModel):
|
|||||||
product_name: str = Field(..., min_length=1, max_length=200)
|
product_name: str = Field(..., min_length=1, max_length=200)
|
||||||
product_sku: Optional[str] = Field(None, max_length=100)
|
product_sku: Optional[str] = Field(None, max_length=100)
|
||||||
product_category: Optional[str] = Field(None, max_length=100)
|
product_category: Optional[str] = Field(None, max_length=100)
|
||||||
product_type: str = Field(default="ingredient", pattern="^(ingredient|packaging|supplies)$")
|
product_type: str = Field(default="ingredient") # TODO: Create ProductType enum if needed
|
||||||
required_quantity: Decimal = Field(..., gt=0)
|
required_quantity: Decimal = Field(..., gt=0)
|
||||||
unit_of_measure: str = Field(..., min_length=1, max_length=50)
|
unit_of_measure: str = Field(..., min_length=1, max_length=50)
|
||||||
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)
|
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)
|
||||||
required_by_date: date
|
required_by_date: date
|
||||||
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
|
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
|
||||||
preferred_supplier_id: Optional[UUID] = None
|
preferred_supplier_id: Optional[UUID] = None
|
||||||
quality_specifications: Optional[Dict[str, Any]] = None
|
quality_specifications: Optional[Dict[str, Any]] = None
|
||||||
special_requirements: Optional[str] = None
|
special_requirements: Optional[str] = None
|
||||||
storage_requirements: Optional[str] = Field(None, max_length=200)
|
storage_requirements: Optional[str] = Field(None, max_length=200)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class ProcurementRequirementCreate(ProcurementRequirementBase):
|
class ProcurementRequirementCreate(ProcurementRequirementBase):
|
||||||
pass
|
pass
|
||||||
@@ -248,13 +275,17 @@ class ProcurementPlanBase(BaseModel):
|
|||||||
plan_period_start: date
|
plan_period_start: date
|
||||||
plan_period_end: date
|
plan_period_end: date
|
||||||
planning_horizon_days: int = Field(default=14, ge=1, le=365)
|
planning_horizon_days: int = Field(default=14, ge=1, le=365)
|
||||||
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$")
|
plan_type: ProcurementPlanType = Field(default=ProcurementPlanType.REGULAR)
|
||||||
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
|
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
|
||||||
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
|
business_model: Optional[BusinessModel] = None
|
||||||
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$")
|
procurement_strategy: ProcurementStrategy = Field(default=ProcurementStrategy.JUST_IN_TIME)
|
||||||
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
|
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
|
||||||
special_requirements: Optional[str] = None
|
special_requirements: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
use_enum_values = True
|
||||||
|
|
||||||
|
|
||||||
class ProcurementPlanCreate(ProcurementPlanBase):
|
class ProcurementPlanCreate(ProcurementPlanBase):
|
||||||
tenant_id: UUID
|
tenant_id: UUID
|
||||||
|
|||||||
@@ -336,10 +336,10 @@ class OrdersService:
|
|||||||
|
|
||||||
# Get new customers this month
|
# Get new customers this month
|
||||||
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
new_customers_this_month = await self.customer_repo.count(
|
new_customers_this_month = await self.customer_repo.count_created_since(
|
||||||
db,
|
db,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
filters={"created_at": {"gte": month_start}}
|
month_start
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get recent orders
|
# Get recent orders
|
||||||
|
|||||||
290
services/orders/scripts/seed_test_data.py
Normal file
290
services/orders/scripts/seed_test_data.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to populate the database with test data for orders and customers
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
|
# Add the parent directory to the path to import our modules
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.core.database import get_session
|
||||||
|
from app.models.customer import Customer, CustomerContact
|
||||||
|
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
|
||||||
|
|
||||||
|
# Test tenant ID - in a real environment this would be provided
|
||||||
|
TEST_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
|
||||||
|
|
||||||
|
# Sample customer data
|
||||||
|
SAMPLE_CUSTOMERS = [
|
||||||
|
{
|
||||||
|
"name": "María García López",
|
||||||
|
"customer_type": "individual",
|
||||||
|
"email": "maria.garcia@email.com",
|
||||||
|
"phone": "+34 612 345 678",
|
||||||
|
"city": "Madrid",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "vip",
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Panadería San Juan",
|
||||||
|
"business_name": "Panadería San Juan S.L.",
|
||||||
|
"customer_type": "business",
|
||||||
|
"email": "pedidos@panaderiasjuan.com",
|
||||||
|
"phone": "+34 687 654 321",
|
||||||
|
"city": "Barcelona",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "wholesale",
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Carlos Rodríguez Martín",
|
||||||
|
"customer_type": "individual",
|
||||||
|
"email": "carlos.rodriguez@email.com",
|
||||||
|
"phone": "+34 698 765 432",
|
||||||
|
"city": "Valencia",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "regular",
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ana Fernández Ruiz",
|
||||||
|
"customer_type": "individual",
|
||||||
|
"email": "ana.fernandez@email.com",
|
||||||
|
"phone": "+34 634 567 890",
|
||||||
|
"city": "Sevilla",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "regular",
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Café Central",
|
||||||
|
"business_name": "Café Central Madrid S.L.",
|
||||||
|
"customer_type": "business",
|
||||||
|
"email": "compras@cafecentral.es",
|
||||||
|
"phone": "+34 623 456 789",
|
||||||
|
"city": "Madrid",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "wholesale",
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Laura Martínez Silva",
|
||||||
|
"customer_type": "individual",
|
||||||
|
"email": "laura.martinez@email.com",
|
||||||
|
"phone": "+34 645 789 012",
|
||||||
|
"city": "Bilbao",
|
||||||
|
"country": "España",
|
||||||
|
"customer_segment": "regular",
|
||||||
|
"is_active": False # Inactive customer for testing
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sample products (in a real system these would come from a products service)
|
||||||
|
SAMPLE_PRODUCTS = [
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Pan Integral Artesano", "price": Decimal("2.50"), "category": "Panadería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Croissant de Mantequilla", "price": Decimal("1.80"), "category": "Bollería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Tarta de Santiago", "price": Decimal("18.90"), "category": "Repostería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Magdalenas de Limón", "price": Decimal("0.90"), "category": "Bollería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Empanada de Atún", "price": Decimal("3.50"), "category": "Salado"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Brownie de Chocolate", "price": Decimal("3.20"), "category": "Repostería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Baguette Francesa", "price": Decimal("2.80"), "category": "Panadería"},
|
||||||
|
{"id": str(uuid.uuid4()), "name": "Palmera de Chocolate", "price": Decimal("2.40"), "category": "Bollería"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_customers(session: AsyncSession) -> list[Customer]:
|
||||||
|
"""Create sample customers"""
|
||||||
|
customers = []
|
||||||
|
|
||||||
|
for i, customer_data in enumerate(SAMPLE_CUSTOMERS):
|
||||||
|
customer = Customer(
|
||||||
|
tenant_id=TEST_TENANT_ID,
|
||||||
|
customer_code=f"CUST-{i+1:04d}",
|
||||||
|
name=customer_data["name"],
|
||||||
|
business_name=customer_data.get("business_name"),
|
||||||
|
customer_type=customer_data["customer_type"],
|
||||||
|
email=customer_data["email"],
|
||||||
|
phone=customer_data["phone"],
|
||||||
|
city=customer_data["city"],
|
||||||
|
country=customer_data["country"],
|
||||||
|
is_active=customer_data["is_active"],
|
||||||
|
preferred_delivery_method="delivery" if random.choice([True, False]) else "pickup",
|
||||||
|
payment_terms=random.choice(["immediate", "net_30"]),
|
||||||
|
customer_segment=customer_data["customer_segment"],
|
||||||
|
priority_level=random.choice(["normal", "high"]) if customer_data["customer_segment"] == "vip" else "normal",
|
||||||
|
discount_percentage=Decimal("5.0") if customer_data["customer_segment"] == "vip" else
|
||||||
|
Decimal("10.0") if customer_data["customer_segment"] == "wholesale" else Decimal("0.0"),
|
||||||
|
total_orders=random.randint(5, 50),
|
||||||
|
total_spent=Decimal(str(random.randint(100, 5000))),
|
||||||
|
average_order_value=Decimal(str(random.randint(15, 150))),
|
||||||
|
last_order_date=datetime.now() - timedelta(days=random.randint(1, 30))
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(customer)
|
||||||
|
customers.append(customer)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return customers
|
||||||
|
|
||||||
|
async def create_orders(session: AsyncSession, customers: list[Customer]):
|
||||||
|
"""Create sample orders in different statuses"""
|
||||||
|
order_statuses = [
|
||||||
|
"pending", "confirmed", "in_production", "ready",
|
||||||
|
"out_for_delivery", "delivered", "cancelled"
|
||||||
|
]
|
||||||
|
|
||||||
|
order_types = ["standard", "rush", "recurring", "special"]
|
||||||
|
priorities = ["low", "normal", "high"]
|
||||||
|
delivery_methods = ["delivery", "pickup"]
|
||||||
|
payment_statuses = ["pending", "partial", "paid", "failed"]
|
||||||
|
|
||||||
|
for i in range(25): # Create 25 sample orders
|
||||||
|
customer = random.choice(customers)
|
||||||
|
order_status = random.choice(order_statuses)
|
||||||
|
|
||||||
|
# Create order date in the last 30 days
|
||||||
|
order_date = datetime.now() - timedelta(days=random.randint(0, 30))
|
||||||
|
|
||||||
|
# Create delivery date (1-7 days after order date)
|
||||||
|
delivery_date = order_date + timedelta(days=random.randint(1, 7))
|
||||||
|
|
||||||
|
order = CustomerOrder(
|
||||||
|
tenant_id=TEST_TENANT_ID,
|
||||||
|
order_number=f"ORD-{datetime.now().year}-{i+1:04d}",
|
||||||
|
customer_id=customer.id,
|
||||||
|
status=order_status,
|
||||||
|
order_type=random.choice(order_types),
|
||||||
|
priority=random.choice(priorities),
|
||||||
|
order_date=order_date,
|
||||||
|
requested_delivery_date=delivery_date,
|
||||||
|
confirmed_delivery_date=delivery_date if order_status not in ["pending", "cancelled"] else None,
|
||||||
|
actual_delivery_date=delivery_date if order_status == "delivered" else None,
|
||||||
|
delivery_method=random.choice(delivery_methods),
|
||||||
|
delivery_instructions=random.choice([
|
||||||
|
None, "Dejar en recepción", "Llamar al timbre", "Cuidado con el escalón"
|
||||||
|
]),
|
||||||
|
discount_percentage=customer.discount_percentage,
|
||||||
|
payment_status=random.choice(payment_statuses) if order_status != "cancelled" else "failed",
|
||||||
|
payment_method=random.choice(["cash", "card", "bank_transfer"]),
|
||||||
|
payment_terms=customer.payment_terms,
|
||||||
|
special_instructions=random.choice([
|
||||||
|
None, "Sin gluten", "Decoración especial", "Entrega temprano", "Cliente VIP"
|
||||||
|
]),
|
||||||
|
order_source=random.choice(["manual", "online", "phone"]),
|
||||||
|
sales_channel=random.choice(["direct", "wholesale"]),
|
||||||
|
customer_notified_confirmed=order_status not in ["pending", "cancelled"],
|
||||||
|
customer_notified_ready=order_status in ["ready", "out_for_delivery", "delivered"],
|
||||||
|
customer_notified_delivered=order_status == "delivered",
|
||||||
|
quality_score=Decimal(str(random.randint(70, 100) / 10)) if order_status == "delivered" else None,
|
||||||
|
customer_rating=random.randint(3, 5) if order_status == "delivered" else None
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(order)
|
||||||
|
await session.flush() # Flush to get the order ID
|
||||||
|
|
||||||
|
# Create order items
|
||||||
|
num_items = random.randint(1, 5)
|
||||||
|
subtotal = Decimal("0.00")
|
||||||
|
|
||||||
|
for _ in range(num_items):
|
||||||
|
product = random.choice(SAMPLE_PRODUCTS)
|
||||||
|
quantity = random.randint(1, 10)
|
||||||
|
unit_price = product["price"]
|
||||||
|
line_total = unit_price * quantity
|
||||||
|
|
||||||
|
order_item = OrderItem(
|
||||||
|
order_id=order.id,
|
||||||
|
product_id=product["id"],
|
||||||
|
product_name=product["name"],
|
||||||
|
product_category=product["category"],
|
||||||
|
quantity=quantity,
|
||||||
|
unit_of_measure="unidad",
|
||||||
|
unit_price=unit_price,
|
||||||
|
line_discount=Decimal("0.00"),
|
||||||
|
line_total=line_total,
|
||||||
|
status=order_status if order_status != "cancelled" else "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(order_item)
|
||||||
|
subtotal += line_total
|
||||||
|
|
||||||
|
# Calculate financial totals
|
||||||
|
discount_amount = subtotal * (order.discount_percentage / 100)
|
||||||
|
tax_amount = (subtotal - discount_amount) * Decimal("0.21") # 21% VAT
|
||||||
|
delivery_fee = Decimal("3.50") if order.delivery_method == "delivery" and subtotal < 25 else Decimal("0.00")
|
||||||
|
total_amount = subtotal - discount_amount + tax_amount + delivery_fee
|
||||||
|
|
||||||
|
# Update order with calculated totals
|
||||||
|
order.subtotal = subtotal
|
||||||
|
order.discount_amount = discount_amount
|
||||||
|
order.tax_amount = tax_amount
|
||||||
|
order.delivery_fee = delivery_fee
|
||||||
|
order.total_amount = total_amount
|
||||||
|
|
||||||
|
# Create status history
|
||||||
|
status_history = OrderStatusHistory(
|
||||||
|
order_id=order.id,
|
||||||
|
from_status=None,
|
||||||
|
to_status=order_status,
|
||||||
|
event_type="status_change",
|
||||||
|
event_description=f"Order created with status: {order_status}",
|
||||||
|
change_source="system",
|
||||||
|
changed_at=order_date,
|
||||||
|
customer_notified=order_status != "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(status_history)
|
||||||
|
|
||||||
|
# Add additional status changes for non-pending orders
|
||||||
|
if order_status != "pending":
|
||||||
|
current_date = order_date
|
||||||
|
for status in ["confirmed", "in_production", "ready"]:
|
||||||
|
if order_statuses.index(status) <= order_statuses.index(order_status):
|
||||||
|
current_date += timedelta(hours=random.randint(2, 12))
|
||||||
|
status_change = OrderStatusHistory(
|
||||||
|
order_id=order.id,
|
||||||
|
from_status="pending" if status == "confirmed" else None,
|
||||||
|
to_status=status,
|
||||||
|
event_type="status_change",
|
||||||
|
event_description=f"Order status changed to: {status}",
|
||||||
|
change_source="manual",
|
||||||
|
changed_at=current_date,
|
||||||
|
customer_notified=True
|
||||||
|
)
|
||||||
|
session.add(status_change)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main function to seed the database"""
|
||||||
|
print("🌱 Starting database seeding...")
|
||||||
|
|
||||||
|
async for session in get_session():
|
||||||
|
try:
|
||||||
|
print("📋 Creating customers...")
|
||||||
|
customers = await create_customers(session)
|
||||||
|
print(f"✅ Created {len(customers)} customers")
|
||||||
|
|
||||||
|
print("📦 Creating orders...")
|
||||||
|
await create_orders(session, customers)
|
||||||
|
print("✅ Created orders with different statuses")
|
||||||
|
|
||||||
|
print("🎉 Database seeding completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error during seeding: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user