feat(sales): complete Sales and Quotations module CRUD pages
Sales Orders: - SalesOrderCreatePage: Full form with line items, validation - SalesOrderDetailPage: View with status-based actions - SalesOrderEditPage: Edit draft orders with line item management Quotations: - QuotationCreatePage: Full form with line items - QuotationDetailPage: View with send/accept/reject/convert actions - QuotationEditPage: Edit draft quotations Components: - SalesOrderForm, SalesOrderLineItems, SalesOrderLineItemForm - SalesOrderSummary, SalesOrderStatusBadge - QuotationStatusBadge, QuotationActions Routes configured for: - /sales/orders, /sales/orders/new, /sales/orders/:id, /sales/orders/:id/edit - /sales/quotations, /sales/quotations/new, /sales/quotations/:id, /sales/quotations/:id/edit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2b2361d87c
commit
36ba16e2a2
@ -44,6 +44,18 @@ const SecuritySettingsPage = lazy(() => import('@pages/settings/SecuritySettings
|
||||
const SystemSettingsPage = lazy(() => import('@pages/settings/SystemSettingsPage').then(m => ({ default: m.SystemSettingsPage })));
|
||||
const AuditLogsPage = lazy(() => import('@pages/settings/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
|
||||
|
||||
// Sales pages
|
||||
const SalesOrdersPage = lazy(() => import('@pages/sales/SalesOrdersPage').then(m => ({ default: m.SalesOrdersPage })));
|
||||
const SalesOrderCreatePage = lazy(() => import('@pages/sales/SalesOrderCreatePage').then(m => ({ default: m.SalesOrderCreatePage })));
|
||||
const SalesOrderDetailPage = lazy(() => import('@pages/sales/SalesOrderDetailPage').then(m => ({ default: m.SalesOrderDetailPage })));
|
||||
const SalesOrderEditPage = lazy(() => import('@pages/sales/SalesOrderEditPage').then(m => ({ default: m.SalesOrderEditPage })));
|
||||
|
||||
// Quotation pages
|
||||
const QuotationsPage = lazy(() => import('@pages/sales/QuotationsPage').then(m => ({ default: m.QuotationsPage })));
|
||||
const QuotationDetailPage = lazy(() => import('@pages/sales/QuotationDetailPage').then(m => ({ default: m.QuotationDetailPage })));
|
||||
const QuotationCreatePage = lazy(() => import('@pages/sales/QuotationCreatePage').then(m => ({ default: m.QuotationCreatePage })));
|
||||
const QuotationEditPage = lazy(() => import('@pages/sales/QuotationEditPage').then(m => ({ default: m.QuotationEditPage })));
|
||||
|
||||
// CRM pages
|
||||
const PipelineKanbanPage = lazy(() => import('@pages/crm/PipelineKanbanPage').then(m => ({ default: m.PipelineKanbanPage })));
|
||||
const LeadsPage = lazy(() => import('@pages/crm/LeadsPage').then(m => ({ default: m.LeadsPage })));
|
||||
@ -212,11 +224,81 @@ export const router = createBrowserRouter([
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
// Sales routes
|
||||
{
|
||||
path: '/sales',
|
||||
element: <Navigate to="/sales/orders" replace />,
|
||||
},
|
||||
{
|
||||
path: '/sales/orders',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SalesOrdersPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/orders/new',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SalesOrderCreatePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/orders/:id',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SalesOrderDetailPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/orders/:id/edit',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<SalesOrderEditPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
// Quotation routes
|
||||
{
|
||||
path: '/sales/quotations',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<QuotationsPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/quotations/new',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<QuotationCreatePage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/quotations/:id',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<QuotationDetailPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/quotations/:id/edit',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<QuotationEditPage />
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/sales/*',
|
||||
element: (
|
||||
<DashboardWrapper>
|
||||
<div className="text-center text-gray-500">Módulo de Ventas - En desarrollo</div>
|
||||
<div className="text-center text-gray-500">Seccion de Ventas - En desarrollo</div>
|
||||
</DashboardWrapper>
|
||||
),
|
||||
},
|
||||
|
||||
@ -9,7 +9,11 @@ import type {
|
||||
SalesOrderFilters,
|
||||
SalesOrdersResponse,
|
||||
Quotation,
|
||||
QuotationLine,
|
||||
CreateQuotationDto,
|
||||
UpdateQuotationDto,
|
||||
CreateQuotationLineDto,
|
||||
UpdateQuotationLineDto,
|
||||
QuotationFilters,
|
||||
QuotationsResponse,
|
||||
} from '../types';
|
||||
@ -147,6 +151,38 @@ export const salesApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update quotation
|
||||
updateQuotation: async (id: string, data: UpdateQuotationDto): Promise<Quotation> => {
|
||||
const response = await api.patch<Quotation>(`${QUOTATIONS_URL}/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete quotation
|
||||
deleteQuotation: async (id: string): Promise<void> => {
|
||||
await api.delete(`${QUOTATIONS_URL}/${id}`);
|
||||
},
|
||||
|
||||
// ==================== Quotation Lines ====================
|
||||
|
||||
// Add line to quotation
|
||||
addQuotationLine: async (quotationId: string, data: CreateQuotationLineDto): Promise<QuotationLine> => {
|
||||
const response = await api.post<QuotationLine>(`${QUOTATIONS_URL}/${quotationId}/lines`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update quotation line
|
||||
updateQuotationLine: async (quotationId: string, lineId: string, data: UpdateQuotationLineDto): Promise<QuotationLine> => {
|
||||
const response = await api.patch<QuotationLine>(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Remove quotation line
|
||||
removeQuotationLine: async (quotationId: string, lineId: string): Promise<void> => {
|
||||
await api.delete(`${QUOTATIONS_URL}/${quotationId}/lines/${lineId}`);
|
||||
},
|
||||
|
||||
// ==================== Quotation Actions ====================
|
||||
|
||||
// Send quotation
|
||||
sendQuotation: async (id: string): Promise<Quotation> => {
|
||||
const response = await api.post<Quotation>(`${QUOTATIONS_URL}/${id}/send`);
|
||||
|
||||
287
src/features/sales/components/QuotationActions.tsx
Normal file
287
src/features/sales/components/QuotationActions.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ShoppingCart,
|
||||
Download,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { salesApi } from '../api/sales.api';
|
||||
import type { Quotation } from '../types';
|
||||
import type { QuotationStatus } from './QuotationStatusBadge';
|
||||
|
||||
interface QuotationActionsProps {
|
||||
quotation: Quotation;
|
||||
onRefresh: () => void;
|
||||
isProcessing?: boolean;
|
||||
}
|
||||
|
||||
export function QuotationActions({ quotation, onRefresh, isProcessing = false }: QuotationActionsProps) {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [showAcceptModal, setShowAcceptModal] = useState(false);
|
||||
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||
const [showConvertModal, setShowConvertModal] = useState(false);
|
||||
const [localProcessing, setLocalProcessing] = useState(false);
|
||||
|
||||
const status = quotation.status as QuotationStatus;
|
||||
|
||||
const canEdit = status === 'draft';
|
||||
const canDelete = status === 'draft';
|
||||
const canSend = status === 'draft';
|
||||
const canAccept = status === 'sent';
|
||||
const canReject = status === 'sent';
|
||||
const canConvert = status === 'accepted';
|
||||
|
||||
const handleDelete = async () => {
|
||||
setLocalProcessing(true);
|
||||
try {
|
||||
await salesApi.deleteQuotation(quotation.id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion eliminada',
|
||||
message: 'La cotizacion ha sido eliminada exitosamente.',
|
||||
});
|
||||
navigate('/sales/quotations');
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo eliminar la cotizacion.',
|
||||
});
|
||||
} finally {
|
||||
setLocalProcessing(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
setLocalProcessing(true);
|
||||
try {
|
||||
await salesApi.sendQuotation(quotation.id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion enviada',
|
||||
message: 'La cotizacion ha sido enviada al cliente.',
|
||||
});
|
||||
onRefresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo enviar la cotizacion.',
|
||||
});
|
||||
} finally {
|
||||
setLocalProcessing(false);
|
||||
setShowSendModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
setLocalProcessing(true);
|
||||
try {
|
||||
await salesApi.acceptQuotation(quotation.id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion aceptada',
|
||||
message: 'La cotizacion ha sido marcada como aceptada.',
|
||||
});
|
||||
onRefresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo aceptar la cotizacion.',
|
||||
});
|
||||
} finally {
|
||||
setLocalProcessing(false);
|
||||
setShowAcceptModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setLocalProcessing(true);
|
||||
try {
|
||||
await salesApi.rejectQuotation(quotation.id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion rechazada',
|
||||
message: 'La cotizacion ha sido marcada como rechazada.',
|
||||
});
|
||||
onRefresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo rechazar la cotizacion.',
|
||||
});
|
||||
} finally {
|
||||
setLocalProcessing(false);
|
||||
setShowRejectModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
setLocalProcessing(true);
|
||||
try {
|
||||
const order = await salesApi.convertToOrder(quotation.id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion convertida',
|
||||
message: `Se ha creado el pedido de venta ${order.name}.`,
|
||||
});
|
||||
navigate(`/sales/orders/${order.id}`);
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo convertir la cotizacion a pedido.',
|
||||
});
|
||||
} finally {
|
||||
setLocalProcessing(false);
|
||||
setShowConvertModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
try {
|
||||
await salesApi.downloadQuotationPdf(quotation.id, `cotizacion-${quotation.quotationNumber}.pdf`);
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo descargar el PDF.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const processing = isProcessing || localProcessing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={onRefresh} disabled={processing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${processing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" onClick={handleDownloadPdf} disabled={processing}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
PDF
|
||||
</Button>
|
||||
|
||||
{canEdit && (
|
||||
<Button variant="outline" onClick={() => navigate(`/sales/quotations/${quotation.id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSend && (
|
||||
<Button variant="primary" onClick={() => setShowSendModal(true)}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Enviar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canAccept && (
|
||||
<Button variant="primary" onClick={() => setShowAcceptModal(true)}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Aceptar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canReject && (
|
||||
<Button variant="outline" onClick={() => setShowRejectModal(true)}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Rechazar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canConvert && (
|
||||
<Button onClick={() => setShowConvertModal(true)}>
|
||||
<ShoppingCart className="mr-2 h-4 w-4" />
|
||||
Convertir a Pedido
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar cotizacion"
|
||||
message={`¿Estas seguro de que deseas eliminar la cotizacion ${quotation.quotationNumber}? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
isLoading={localProcessing}
|
||||
/>
|
||||
|
||||
{/* Send Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
onConfirm={handleSend}
|
||||
title="Enviar cotizacion"
|
||||
message={`¿Enviar la cotizacion ${quotation.quotationNumber} al cliente?`}
|
||||
variant="info"
|
||||
confirmText="Enviar"
|
||||
isLoading={localProcessing}
|
||||
/>
|
||||
|
||||
{/* Accept Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showAcceptModal}
|
||||
onClose={() => setShowAcceptModal(false)}
|
||||
onConfirm={handleAccept}
|
||||
title="Aceptar cotizacion"
|
||||
message={`¿Marcar la cotizacion ${quotation.quotationNumber} como aceptada por el cliente?`}
|
||||
variant="success"
|
||||
confirmText="Aceptar"
|
||||
isLoading={localProcessing}
|
||||
/>
|
||||
|
||||
{/* Reject Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showRejectModal}
|
||||
onClose={() => setShowRejectModal(false)}
|
||||
onConfirm={handleReject}
|
||||
title="Rechazar cotizacion"
|
||||
message={`¿Marcar la cotizacion ${quotation.quotationNumber} como rechazada?`}
|
||||
variant="warning"
|
||||
confirmText="Rechazar"
|
||||
isLoading={localProcessing}
|
||||
/>
|
||||
|
||||
{/* Convert Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showConvertModal}
|
||||
onClose={() => setShowConvertModal(false)}
|
||||
onConfirm={handleConvert}
|
||||
title="Convertir a pedido"
|
||||
message={`¿Convertir la cotizacion ${quotation.quotationNumber} en un pedido de venta?`}
|
||||
variant="success"
|
||||
confirmText="Convertir"
|
||||
isLoading={localProcessing}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/features/sales/components/QuotationStatusBadge.tsx
Normal file
24
src/features/sales/components/QuotationStatusBadge.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Badge } from '@components/atoms/Badge';
|
||||
|
||||
export type QuotationStatus = 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted';
|
||||
|
||||
interface QuotationStatusBadgeProps {
|
||||
status: QuotationStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
QuotationStatus,
|
||||
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
|
||||
> = {
|
||||
draft: { label: 'Borrador', variant: 'default' },
|
||||
sent: { label: 'Enviada', variant: 'info' },
|
||||
accepted: { label: 'Aceptada', variant: 'success' },
|
||||
rejected: { label: 'Rechazada', variant: 'danger' },
|
||||
expired: { label: 'Expirada', variant: 'warning' },
|
||||
converted: { label: 'Convertida', variant: 'primary' },
|
||||
};
|
||||
|
||||
export function QuotationStatusBadge({ status }: QuotationStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
418
src/features/sales/components/SalesOrderForm.tsx
Normal file
418
src/features/sales/components/SalesOrderForm.tsx
Normal file
@ -0,0 +1,418 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { DatePicker } from '@components/organisms/DatePicker';
|
||||
import { SalesOrderLineItems } from './SalesOrderLineItems';
|
||||
import { SalesOrderSummary } from './SalesOrderSummary';
|
||||
import type {
|
||||
SalesOrder,
|
||||
CreateSalesOrderDto,
|
||||
UpdateSalesOrderDto,
|
||||
SalesOrderLine,
|
||||
CreateSalesOrderLineDto,
|
||||
InvoicePolicy,
|
||||
} from '../types';
|
||||
|
||||
const salesOrderSchema = z.object({
|
||||
companyId: z.string().min(1, 'Selecciona una empresa'),
|
||||
partnerId: z.string().min(1, 'Selecciona un cliente'),
|
||||
clientOrderRef: z.string().optional(),
|
||||
orderDate: z.date().optional(),
|
||||
validityDate: z.date().optional(),
|
||||
commitmentDate: z.date().optional(),
|
||||
currencyId: z.string().min(1, 'Selecciona una moneda'),
|
||||
pricelistId: z.string().optional(),
|
||||
paymentTermId: z.string().optional(),
|
||||
salesTeamId: z.string().optional(),
|
||||
invoicePolicy: z.enum(['order', 'delivery'] as const).optional(),
|
||||
notes: z.string().optional(),
|
||||
termsConditions: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof salesOrderSchema>;
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProductOption extends SelectOption {
|
||||
defaultPrice?: number;
|
||||
defaultUomId?: string;
|
||||
}
|
||||
|
||||
interface SalesOrderFormProps {
|
||||
initialData?: SalesOrder;
|
||||
onSubmit: (data: CreateSalesOrderDto | UpdateSalesOrderDto) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
mode: 'create' | 'edit';
|
||||
isLoading?: boolean;
|
||||
companies?: SelectOption[];
|
||||
partners?: SelectOption[];
|
||||
currencies?: SelectOption[];
|
||||
pricelists?: SelectOption[];
|
||||
paymentTerms?: SelectOption[];
|
||||
salesTeams?: SelectOption[];
|
||||
products?: ProductOption[];
|
||||
uoms?: SelectOption[];
|
||||
taxes?: SelectOption[];
|
||||
lines?: SalesOrderLine[];
|
||||
onAddLine?: (data: CreateSalesOrderLineDto) => Promise<void>;
|
||||
onUpdateLine?: (lineId: string, data: Partial<CreateSalesOrderLineDto>) => Promise<void>;
|
||||
onRemoveLine?: (lineId: string) => Promise<void>;
|
||||
linesLoading?: boolean;
|
||||
}
|
||||
|
||||
const invoicePolicyOptions: SelectOption[] = [
|
||||
{ value: 'order', label: 'Cantidades ordenadas' },
|
||||
{ value: 'delivery', label: 'Cantidades entregadas' },
|
||||
];
|
||||
|
||||
export function SalesOrderForm({
|
||||
initialData,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
mode,
|
||||
isLoading = false,
|
||||
companies = [],
|
||||
partners = [],
|
||||
currencies = [],
|
||||
pricelists = [],
|
||||
paymentTerms = [],
|
||||
salesTeams = [],
|
||||
products = [],
|
||||
uoms = [],
|
||||
taxes = [],
|
||||
lines = [],
|
||||
onAddLine,
|
||||
onUpdateLine,
|
||||
onRemoveLine,
|
||||
linesLoading = false,
|
||||
}: SalesOrderFormProps) {
|
||||
const isEditing = mode === 'edit';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(salesOrderSchema),
|
||||
defaultValues: initialData
|
||||
? {
|
||||
companyId: initialData.companyId,
|
||||
partnerId: initialData.partnerId,
|
||||
clientOrderRef: initialData.clientOrderRef || '',
|
||||
orderDate: initialData.orderDate ? new Date(initialData.orderDate) : undefined,
|
||||
validityDate: initialData.validityDate
|
||||
? new Date(initialData.validityDate)
|
||||
: undefined,
|
||||
commitmentDate: initialData.commitmentDate
|
||||
? new Date(initialData.commitmentDate)
|
||||
: undefined,
|
||||
currencyId: initialData.currencyId,
|
||||
pricelistId: initialData.pricelistId || '',
|
||||
paymentTermId: initialData.paymentTermId || '',
|
||||
salesTeamId: initialData.salesTeamId || '',
|
||||
invoicePolicy: initialData.invoicePolicy,
|
||||
notes: initialData.notes || '',
|
||||
termsConditions: initialData.termsConditions || '',
|
||||
}
|
||||
: {
|
||||
companyId: '',
|
||||
partnerId: '',
|
||||
clientOrderRef: '',
|
||||
orderDate: new Date(),
|
||||
validityDate: undefined,
|
||||
commitmentDate: undefined,
|
||||
currencyId: '',
|
||||
pricelistId: '',
|
||||
paymentTermId: '',
|
||||
salesTeamId: '',
|
||||
invoicePolicy: 'order' as InvoicePolicy,
|
||||
notes: '',
|
||||
termsConditions: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCompanyId = watch('companyId');
|
||||
const selectedPartnerId = watch('partnerId');
|
||||
const selectedCurrencyId = watch('currencyId');
|
||||
const selectedPricelistId = watch('pricelistId');
|
||||
const selectedPaymentTermId = watch('paymentTermId');
|
||||
const selectedSalesTeamId = watch('salesTeamId');
|
||||
const selectedInvoicePolicy = watch('invoicePolicy');
|
||||
const orderDate = watch('orderDate');
|
||||
const validityDate = watch('validityDate');
|
||||
const commitmentDate = watch('commitmentDate');
|
||||
|
||||
const totals = lines.reduce(
|
||||
(acc, line) => ({
|
||||
subtotal: acc.subtotal + line.amountUntaxed,
|
||||
taxAmount: acc.taxAmount + line.amountTax,
|
||||
total: acc.total + line.amountTotal,
|
||||
}),
|
||||
{ subtotal: 0, taxAmount: 0, total: 0 }
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (data: FormData) => {
|
||||
const formattedData: CreateSalesOrderDto | UpdateSalesOrderDto = {
|
||||
companyId: data.companyId,
|
||||
partnerId: data.partnerId,
|
||||
currencyId: data.currencyId,
|
||||
...(data.clientOrderRef && { clientOrderRef: data.clientOrderRef }),
|
||||
...(data.orderDate && { orderDate: data.orderDate.toISOString().split('T')[0] }),
|
||||
...(data.validityDate && { validityDate: data.validityDate.toISOString().split('T')[0] }),
|
||||
...(data.commitmentDate && {
|
||||
commitmentDate: data.commitmentDate.toISOString().split('T')[0],
|
||||
}),
|
||||
...(data.pricelistId && { pricelistId: data.pricelistId }),
|
||||
...(data.paymentTermId && { paymentTermId: data.paymentTermId }),
|
||||
...(data.salesTeamId && { salesTeamId: data.salesTeamId }),
|
||||
...(data.invoicePolicy && { invoicePolicy: data.invoicePolicy }),
|
||||
...(data.notes && { notes: data.notes }),
|
||||
...(data.termsConditions && { termsConditions: data.termsConditions }),
|
||||
};
|
||||
|
||||
await onSubmit(formattedData);
|
||||
};
|
||||
|
||||
const handleAddLine = async (data: CreateSalesOrderLineDto) => {
|
||||
if (onAddLine) {
|
||||
await onAddLine(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateLine = async (lineId: string, data: Partial<CreateSalesOrderLineDto>) => {
|
||||
if (onUpdateLine) {
|
||||
await onUpdateLine(lineId, data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLine = async (lineId: string) => {
|
||||
if (onRemoveLine) {
|
||||
await onRemoveLine(lineId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Informacion general
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{!isEditing && (
|
||||
<FormField
|
||||
label="Empresa"
|
||||
error={errors.companyId?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={companies}
|
||||
value={selectedCompanyId}
|
||||
onChange={(value) => setValue('companyId', value as string)}
|
||||
placeholder="Seleccionar empresa..."
|
||||
searchable
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Cliente"
|
||||
error={errors.partnerId?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={partners}
|
||||
value={selectedPartnerId}
|
||||
onChange={(value) => setValue('partnerId', value as string)}
|
||||
placeholder="Buscar cliente..."
|
||||
searchable
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Referencia del cliente"
|
||||
error={errors.clientOrderRef?.message}
|
||||
>
|
||||
<input
|
||||
{...register('clientOrderRef')}
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="PO-12345"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Fechas</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FormField label="Fecha del pedido">
|
||||
<DatePicker
|
||||
value={orderDate}
|
||||
onChange={(date) => setValue('orderDate', date || undefined)}
|
||||
placeholder="Seleccionar fecha..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Fecha de validez">
|
||||
<DatePicker
|
||||
value={validityDate}
|
||||
onChange={(date) => setValue('validityDate', date || undefined)}
|
||||
placeholder="Seleccionar fecha..."
|
||||
minDate={orderDate}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Fecha de compromiso">
|
||||
<DatePicker
|
||||
value={commitmentDate}
|
||||
onChange={(date) => setValue('commitmentDate', date || undefined)}
|
||||
placeholder="Seleccionar fecha..."
|
||||
minDate={orderDate}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Facturacion y precios
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FormField
|
||||
label="Moneda"
|
||||
error={errors.currencyId?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={currencies}
|
||||
value={selectedCurrencyId}
|
||||
onChange={(value) => setValue('currencyId', value as string)}
|
||||
placeholder="Seleccionar moneda..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Lista de precios">
|
||||
<Select
|
||||
options={pricelists}
|
||||
value={selectedPricelistId || ''}
|
||||
onChange={(value) => setValue('pricelistId', value as string)}
|
||||
placeholder="Seleccionar lista..."
|
||||
clearable
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Terminos de pago">
|
||||
<Select
|
||||
options={paymentTerms}
|
||||
value={selectedPaymentTermId || ''}
|
||||
onChange={(value) => setValue('paymentTermId', value as string)}
|
||||
placeholder="Seleccionar terminos..."
|
||||
clearable
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Equipo de ventas">
|
||||
<Select
|
||||
options={salesTeams}
|
||||
value={selectedSalesTeamId || ''}
|
||||
onChange={(value) => setValue('salesTeamId', value as string)}
|
||||
placeholder="Seleccionar equipo..."
|
||||
clearable
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Politica de facturacion">
|
||||
<Select
|
||||
options={invoicePolicyOptions}
|
||||
value={selectedInvoicePolicy || 'order'}
|
||||
onChange={(value) => setValue('invoicePolicy', value as InvoicePolicy)}
|
||||
placeholder="Seleccionar politica..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line Items Section - Only show in edit mode or when there's an order */}
|
||||
{isEditing && (
|
||||
<div className="space-y-4">
|
||||
<SalesOrderLineItems
|
||||
items={lines}
|
||||
onAddItem={handleAddLine}
|
||||
onUpdateItem={handleUpdateLine}
|
||||
onRemoveItem={handleRemoveLine}
|
||||
currencyCode={
|
||||
currencies.find((c) => c.value === selectedCurrencyId)?.label?.substring(0, 3) ||
|
||||
'MXN'
|
||||
}
|
||||
isLoading={linesLoading}
|
||||
products={products}
|
||||
uoms={uoms}
|
||||
taxes={taxes}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SalesOrderSummary
|
||||
subtotal={totals.subtotal}
|
||||
taxAmount={totals.taxAmount}
|
||||
total={totals.total}
|
||||
currencyCode={
|
||||
currencies.find((c) => c.value === selectedCurrencyId)?.label?.substring(0, 3) ||
|
||||
'MXN'
|
||||
}
|
||||
className="w-full sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Notas</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<FormField label="Notas internas">
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Notas internas del pedido..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Terminos y condiciones">
|
||||
<textarea
|
||||
{...register('termsConditions')}
|
||||
rows={3}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Terminos y condiciones para el cliente..."
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 border-t border-gray-200 pt-6 dark:border-gray-700">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEditing ? 'Guardar cambios' : 'Crear pedido'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
265
src/features/sales/components/SalesOrderLineItemForm.tsx
Normal file
265
src/features/sales/components/SalesOrderLineItemForm.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { FormField } from '@components/molecules/FormField';
|
||||
import { Select } from '@components/organisms/Select';
|
||||
import { Modal, ModalContent, ModalFooter } from '@components/organisms/Modal';
|
||||
import type { SalesOrderLine, CreateSalesOrderLineDto } from '../types';
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
productId: z.string().min(1, 'Selecciona un producto'),
|
||||
description: z.string().min(1, 'Descripcion requerida'),
|
||||
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
|
||||
uomId: z.string().min(1, 'Selecciona una unidad de medida'),
|
||||
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
|
||||
discount: z.number().min(0).max(100).optional(),
|
||||
taxIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof lineItemSchema>;
|
||||
|
||||
interface ProductOption {
|
||||
value: string;
|
||||
label: string;
|
||||
defaultPrice?: number;
|
||||
defaultUomId?: string;
|
||||
}
|
||||
|
||||
interface UomOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TaxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SalesOrderLineItemFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
item?: SalesOrderLine;
|
||||
onSave: (data: CreateSalesOrderLineDto) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
products?: ProductOption[];
|
||||
uoms?: UomOption[];
|
||||
taxes?: TaxOption[];
|
||||
}
|
||||
|
||||
export function SalesOrderLineItemForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
item,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
products = [],
|
||||
uoms = [],
|
||||
taxes = [],
|
||||
}: SalesOrderLineItemFormProps) {
|
||||
const isEditing = !!item;
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(lineItemSchema),
|
||||
defaultValues: item
|
||||
? {
|
||||
productId: item.productId,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
uomId: item.uomId,
|
||||
priceUnit: item.priceUnit,
|
||||
discount: item.discount,
|
||||
taxIds: item.taxIds,
|
||||
}
|
||||
: {
|
||||
productId: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
uomId: '',
|
||||
priceUnit: 0,
|
||||
discount: 0,
|
||||
taxIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedProductId = watch('productId');
|
||||
const selectedUomId = watch('uomId');
|
||||
const selectedTaxIds = watch('taxIds') || [];
|
||||
const quantity = watch('quantity');
|
||||
const priceUnit = watch('priceUnit');
|
||||
const discount = watch('discount') || 0;
|
||||
|
||||
const subtotal = quantity * priceUnit * (1 - discount / 100);
|
||||
|
||||
const handleProductChange = (productId: string) => {
|
||||
setValue('productId', productId);
|
||||
const product = products.find((p) => p.value === productId);
|
||||
if (product) {
|
||||
if (!item) {
|
||||
setValue('description', product.label);
|
||||
}
|
||||
if (product.defaultPrice !== undefined) {
|
||||
setValue('priceUnit', product.defaultPrice);
|
||||
}
|
||||
if (product.defaultUomId) {
|
||||
setValue('uomId', product.defaultUomId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: FormData) => {
|
||||
await onSave({
|
||||
productId: data.productId,
|
||||
description: data.description,
|
||||
quantity: data.quantity,
|
||||
uomId: data.uomId,
|
||||
priceUnit: data.priceUnit,
|
||||
discount: data.discount,
|
||||
taxIds: data.taxIds,
|
||||
});
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={isEditing ? 'Editar linea' : 'Agregar linea'}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<ModalContent className="space-y-4">
|
||||
<FormField
|
||||
label="Producto"
|
||||
error={errors.productId?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={products}
|
||||
value={selectedProductId}
|
||||
onChange={(value) => handleProductChange(value as string)}
|
||||
placeholder="Buscar producto..."
|
||||
searchable
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Descripcion"
|
||||
error={errors.description?.message}
|
||||
required
|
||||
>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
placeholder="Descripcion del producto"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
label="Cantidad"
|
||||
error={errors.quantity?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('quantity', { valueAsNumber: true })}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Unidad"
|
||||
error={errors.uomId?.message}
|
||||
required
|
||||
>
|
||||
<Select
|
||||
options={uoms}
|
||||
value={selectedUomId}
|
||||
onChange={(value) => setValue('uomId', value as string)}
|
||||
placeholder="Seleccionar..."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Precio unitario"
|
||||
error={errors.priceUnit?.message}
|
||||
required
|
||||
>
|
||||
<input
|
||||
{...register('priceUnit', { valueAsNumber: true })}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
label="Descuento (%)"
|
||||
error={errors.discount?.message}
|
||||
>
|
||||
<input
|
||||
{...register('discount', { valueAsNumber: true })}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Impuestos">
|
||||
<Select
|
||||
options={taxes}
|
||||
value={selectedTaxIds}
|
||||
onChange={(value) => setValue('taxIds', value as string[])}
|
||||
placeholder="Seleccionar impuestos..."
|
||||
multiple
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span className="text-gray-600 dark:text-gray-400">Subtotal</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">
|
||||
{new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
}).format(subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
|
||||
<ModalFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEditing ? 'Guardar cambios' : 'Agregar'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
284
src/features/sales/components/SalesOrderLineItems.tsx
Normal file
284
src/features/sales/components/SalesOrderLineItems.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { cn } from '@utils/cn';
|
||||
import { SalesOrderLineItemForm } from './SalesOrderLineItemForm';
|
||||
import type { SalesOrderLine, CreateSalesOrderLineDto } from '../types';
|
||||
|
||||
interface ProductOption {
|
||||
value: string;
|
||||
label: string;
|
||||
defaultPrice?: number;
|
||||
defaultUomId?: string;
|
||||
}
|
||||
|
||||
interface UomOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TaxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SalesOrderLineItemsProps {
|
||||
items: SalesOrderLine[];
|
||||
onAddItem: (data: CreateSalesOrderLineDto) => Promise<void>;
|
||||
onUpdateItem: (lineId: string, data: Partial<CreateSalesOrderLineDto>) => Promise<void>;
|
||||
onRemoveItem: (lineId: string) => Promise<void>;
|
||||
currencyCode?: string;
|
||||
isLoading?: boolean;
|
||||
readOnly?: boolean;
|
||||
products?: ProductOption[];
|
||||
uoms?: UomOption[];
|
||||
taxes?: TaxOption[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currencyCode: string = 'MXN'): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function SalesOrderLineItems({
|
||||
items,
|
||||
onAddItem,
|
||||
onUpdateItem,
|
||||
onRemoveItem,
|
||||
currencyCode = 'MXN',
|
||||
isLoading = false,
|
||||
readOnly = false,
|
||||
products = [],
|
||||
uoms = [],
|
||||
taxes = [],
|
||||
className,
|
||||
}: SalesOrderLineItemsProps) {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<SalesOrderLine | undefined>(undefined);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const handleAddClick = () => {
|
||||
setEditingItem(undefined);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleEditClick = (item: SalesOrderLine) => {
|
||||
setEditingItem(item);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveClick = async (lineId: string) => {
|
||||
setDeletingId(lineId);
|
||||
try {
|
||||
await onRemoveItem(lineId);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (data: CreateSalesOrderLineDto) => {
|
||||
if (editingItem) {
|
||||
await onUpdateItem(editingItem.id, data);
|
||||
} else {
|
||||
await onAddItem(data);
|
||||
}
|
||||
};
|
||||
|
||||
const totals = items.reduce(
|
||||
(acc, item) => ({
|
||||
untaxed: acc.untaxed + item.amountUntaxed,
|
||||
tax: acc.tax + item.amountTax,
|
||||
total: acc.total + item.amountTotal,
|
||||
}),
|
||||
{ untaxed: 0, tax: 0, total: 0 }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
Lineas del pedido
|
||||
</h3>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddClick}
|
||||
leftIcon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
Agregar linea
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Producto
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Descripcion
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Cantidad
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Precio unit.
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Descuento
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Impuesto
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Subtotal
|
||||
</th>
|
||||
{!readOnly && (
|
||||
<th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Acciones
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={readOnly ? 7 : 8}
|
||||
className="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No hay lineas en el pedido.{' '}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddClick}
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||
>
|
||||
Agregar una linea
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.productName || item.productId}
|
||||
</td>
|
||||
<td className="max-w-xs truncate px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.description}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.quantity} {item.uomName}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatCurrency(item.priceUnit, currencyCode)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.discount > 0 ? `${item.discount}%` : '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatCurrency(item.amountTax, currencyCode)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(item.amountTotal, currencyCode)}
|
||||
</td>
|
||||
{!readOnly && (
|
||||
<td className="whitespace-nowrap px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditClick(item)}
|
||||
disabled={isLoading}
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveClick(item.id)}
|
||||
disabled={isLoading}
|
||||
isLoading={deletingId === item.id}
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-danger-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
{items.length > 0 && (
|
||||
<tfoot className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<td
|
||||
colSpan={readOnly ? 5 : 6}
|
||||
className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Subtotal
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(totals.untaxed, currencyCode)}
|
||||
</td>
|
||||
{!readOnly && <td />}
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={readOnly ? 5 : 6}
|
||||
className="px-4 py-3 text-right text-sm font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Impuestos
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(totals.tax, currencyCode)}
|
||||
</td>
|
||||
{!readOnly && <td />}
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
colSpan={readOnly ? 5 : 6}
|
||||
className="px-4 py-3 text-right text-sm font-semibold text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
Total
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(totals.total, currencyCode)}
|
||||
</td>
|
||||
{!readOnly && <td />}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<SalesOrderLineItemForm
|
||||
isOpen={isFormOpen}
|
||||
onClose={() => setIsFormOpen(false)}
|
||||
item={editingItem}
|
||||
onSave={handleSave}
|
||||
isLoading={isLoading}
|
||||
products={products}
|
||||
uoms={uoms}
|
||||
taxes={taxes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/features/sales/components/SalesOrderStatusBadge.tsx
Normal file
22
src/features/sales/components/SalesOrderStatusBadge.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Badge } from '@components/atoms/Badge';
|
||||
import type { SalesOrderStatus } from '../types';
|
||||
|
||||
interface SalesOrderStatusBadgeProps {
|
||||
status: SalesOrderStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<
|
||||
SalesOrderStatus,
|
||||
{ label: string; variant: 'success' | 'danger' | 'warning' | 'default' | 'info' | 'primary' }
|
||||
> = {
|
||||
draft: { label: 'Borrador', variant: 'default' },
|
||||
sent: { label: 'Enviado', variant: 'info' },
|
||||
sale: { label: 'Venta', variant: 'primary' },
|
||||
done: { label: 'Completado', variant: 'success' },
|
||||
cancelled: { label: 'Cancelado', variant: 'danger' },
|
||||
};
|
||||
|
||||
export function SalesOrderStatusBadge({ status }: SalesOrderStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
}
|
||||
76
src/features/sales/components/SalesOrderSummary.tsx
Normal file
76
src/features/sales/components/SalesOrderSummary.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { cn } from '@utils/cn';
|
||||
|
||||
interface SalesOrderSummaryProps {
|
||||
subtotal: number;
|
||||
taxAmount: number;
|
||||
discountAmount?: number;
|
||||
shippingAmount?: number;
|
||||
total: number;
|
||||
currencyCode?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatCurrency(amount: number, currencyCode: string = 'MXN'): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function SalesOrderSummary({
|
||||
subtotal,
|
||||
taxAmount,
|
||||
discountAmount = 0,
|
||||
shippingAmount = 0,
|
||||
total,
|
||||
currencyCode = 'MXN',
|
||||
className,
|
||||
}: SalesOrderSummaryProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h4 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
Resumen del pedido
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(subtotal, currencyCode)}</span>
|
||||
</div>
|
||||
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-danger-600 dark:text-danger-400">
|
||||
<span>Descuento</span>
|
||||
<span>-{formatCurrency(discountAmount, currencyCode)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Impuestos</span>
|
||||
<span>{formatCurrency(taxAmount, currencyCode)}</span>
|
||||
</div>
|
||||
|
||||
{shippingAmount > 0 && (
|
||||
<div className="flex justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>Envio</span>
|
||||
<span>{formatCurrency(shippingAmount, currencyCode)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 pt-2 dark:border-gray-700">
|
||||
<div className="flex justify-between font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span>Total</span>
|
||||
<span className="text-lg">{formatCurrency(total, currencyCode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
src/features/sales/components/index.ts
Normal file
10
src/features/sales/components/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// Sales Order Components
|
||||
export { SalesOrderForm } from './SalesOrderForm';
|
||||
export { SalesOrderLineItems } from './SalesOrderLineItems';
|
||||
export { SalesOrderLineItemForm } from './SalesOrderLineItemForm';
|
||||
export { SalesOrderSummary } from './SalesOrderSummary';
|
||||
export { SalesOrderStatusBadge } from './SalesOrderStatusBadge';
|
||||
|
||||
// Quotation Components
|
||||
export { QuotationStatusBadge, type QuotationStatus } from './QuotationStatusBadge';
|
||||
export { QuotationActions } from './QuotationActions';
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './api/sales.api';
|
||||
export * from './types';
|
||||
export * from './hooks';
|
||||
export * from './components';
|
||||
|
||||
@ -185,6 +185,32 @@ export interface CreateQuotationDto {
|
||||
termsConditions?: string;
|
||||
}
|
||||
|
||||
export interface UpdateQuotationDto {
|
||||
partnerId?: string;
|
||||
quotationDate?: string;
|
||||
validityDate?: string | null;
|
||||
currencyId?: string;
|
||||
notes?: string | null;
|
||||
termsConditions?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateQuotationLineDto {
|
||||
productId: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
uomId: string;
|
||||
priceUnit: number;
|
||||
discount?: number;
|
||||
}
|
||||
|
||||
export interface UpdateQuotationLineDto {
|
||||
description?: string;
|
||||
quantity?: number;
|
||||
uomId?: string;
|
||||
priceUnit?: number;
|
||||
discount?: number;
|
||||
}
|
||||
|
||||
export interface QuotationFilters {
|
||||
companyId?: string;
|
||||
partnerId?: string;
|
||||
|
||||
638
src/pages/sales/QuotationCreatePage.tsx
Normal file
638
src/pages/sales/QuotationCreatePage.tsx
Normal file
@ -0,0 +1,638 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
Package,
|
||||
Calculator,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Label } from '@components/atoms/Label';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Select, type SelectOption } from '@components/organisms/Select';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { useQuotations } from '@features/sales/hooks';
|
||||
import { useCustomers } from '@features/partners/hooks';
|
||||
import { useCompanyStore } from '@stores/useCompanyStore';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
interface LineItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
uomId: string;
|
||||
uomName: string;
|
||||
priceUnit: number;
|
||||
discount: number;
|
||||
taxRate: number;
|
||||
}
|
||||
|
||||
// ==================== Validation Schema ====================
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
id: z.string(),
|
||||
productId: z.string().min(1, 'Producto requerido'),
|
||||
productName: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
|
||||
uomId: z.string().min(1, 'Unidad requerida'),
|
||||
uomName: z.string(),
|
||||
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
|
||||
discount: z.number().min(0).max(100, 'Descuento debe estar entre 0 y 100'),
|
||||
taxRate: z.number().min(0).max(100),
|
||||
});
|
||||
|
||||
const quotationSchema = z.object({
|
||||
partnerId: z.string().min(1, 'Cliente es requerido'),
|
||||
quotationDate: z.string().min(1, 'Fecha de cotizacion requerida'),
|
||||
validityDate: z.string().optional(),
|
||||
currencyId: z.string().min(1, 'Moneda requerida'),
|
||||
notes: z.string().optional(),
|
||||
termsConditions: z.string().optional(),
|
||||
lines: z.array(lineItemSchema).min(1, 'Debe agregar al menos un producto'),
|
||||
});
|
||||
|
||||
type QuotationFormData = z.infer<typeof quotationSchema>;
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
const generateTempId = () => `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Mock products for demo (should come from products API)
|
||||
const mockProducts: SelectOption[] = [
|
||||
{ value: 'prod-001', label: 'Producto A - SKU001' },
|
||||
{ value: 'prod-002', label: 'Producto B - SKU002' },
|
||||
{ value: 'prod-003', label: 'Producto C - SKU003' },
|
||||
{ value: 'prod-004', label: 'Servicio Premium' },
|
||||
{ value: 'prod-005', label: 'Licencia Software Anual' },
|
||||
];
|
||||
|
||||
// Mock UOMs (should come from UOM API)
|
||||
const mockUoms: SelectOption[] = [
|
||||
{ value: 'uom-001', label: 'Unidad' },
|
||||
{ value: 'uom-002', label: 'Kg' },
|
||||
{ value: 'uom-003', label: 'Litro' },
|
||||
{ value: 'uom-004', label: 'Hora' },
|
||||
{ value: 'uom-005', label: 'Caja' },
|
||||
];
|
||||
|
||||
// Mock currencies (should come from currency API)
|
||||
const mockCurrencies: SelectOption[] = [
|
||||
{ value: 'curr-001', label: 'MXN - Peso Mexicano' },
|
||||
{ value: 'curr-002', label: 'USD - Dolar Estadounidense' },
|
||||
{ value: 'curr-003', label: 'EUR - Euro' },
|
||||
];
|
||||
|
||||
// Default tax rate (16% IVA for Mexico)
|
||||
const DEFAULT_TAX_RATE = 16;
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
export function QuotationCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { currentCompany } = useCompanyStore();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// API hooks
|
||||
const { createQuotation } = useQuotations({ autoFetch: false });
|
||||
const { partners: customers, isLoading: loadingCustomers } = useCustomers({ limit: 100 });
|
||||
|
||||
// Calculate default validity date (30 days from now)
|
||||
const defaultValidityDate = new Date();
|
||||
defaultValidityDate.setDate(defaultValidityDate.getDate() + 30);
|
||||
|
||||
// Form setup
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<QuotationFormData>({
|
||||
resolver: zodResolver(quotationSchema),
|
||||
defaultValues: {
|
||||
partnerId: '',
|
||||
quotationDate: new Date().toISOString().split('T')[0],
|
||||
validityDate: defaultValidityDate.toISOString().split('T')[0],
|
||||
currencyId: 'curr-001',
|
||||
notes: '',
|
||||
termsConditions: '',
|
||||
lines: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'lines',
|
||||
});
|
||||
|
||||
// Watch for calculations
|
||||
const watchLines = watch('lines');
|
||||
|
||||
// Calculate totals
|
||||
const calculateLineTotals = useCallback((line: LineItem) => {
|
||||
const subtotal = line.quantity * line.priceUnit;
|
||||
const discountAmt = subtotal * (line.discount / 100);
|
||||
const afterDiscount = subtotal - discountAmt;
|
||||
const taxAmt = afterDiscount * (line.taxRate / 100);
|
||||
const total = afterDiscount + taxAmt;
|
||||
return { subtotal, discountAmt, afterDiscount, taxAmt, total };
|
||||
}, []);
|
||||
|
||||
const totals = watchLines.reduce(
|
||||
(acc, line) => {
|
||||
const lineTotals = calculateLineTotals(line);
|
||||
return {
|
||||
subtotal: acc.subtotal + lineTotals.subtotal,
|
||||
lineDiscount: acc.lineDiscount + lineTotals.discountAmt,
|
||||
taxAmount: acc.taxAmount + lineTotals.taxAmt,
|
||||
lineTotal: acc.lineTotal + lineTotals.total,
|
||||
};
|
||||
},
|
||||
{ subtotal: 0, lineDiscount: 0, taxAmount: 0, lineTotal: 0 }
|
||||
);
|
||||
|
||||
// Build customer options
|
||||
const customerOptions: SelectOption[] = customers.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}));
|
||||
|
||||
// Add new line item
|
||||
const handleAddLine = () => {
|
||||
append({
|
||||
id: generateTempId(),
|
||||
productId: '',
|
||||
productName: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
uomId: 'uom-001',
|
||||
uomName: 'Unidad',
|
||||
priceUnit: 0,
|
||||
discount: 0,
|
||||
taxRate: DEFAULT_TAX_RATE,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle product selection for a line
|
||||
const handleProductChange = (index: number, productId: string) => {
|
||||
const product = mockProducts.find((p) => p.value === productId);
|
||||
if (product) {
|
||||
setValue(`lines.${index}.productId`, productId);
|
||||
setValue(`lines.${index}.productName`, product.label);
|
||||
setValue(`lines.${index}.description`, product.label);
|
||||
// In real implementation, fetch product price from API
|
||||
setValue(`lines.${index}.priceUnit`, Math.random() * 1000 + 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle UOM selection for a line
|
||||
const handleUomChange = (index: number, uomId: string) => {
|
||||
const uom = mockUoms.find((u) => u.value === uomId);
|
||||
if (uom) {
|
||||
setValue(`lines.${index}.uomId`, uomId);
|
||||
setValue(`lines.${index}.uomName`, uom.label);
|
||||
}
|
||||
};
|
||||
|
||||
// Submit handler
|
||||
const onSubmit = async (data: QuotationFormData) => {
|
||||
if (!currentCompany) {
|
||||
setError('No hay una empresa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const quotationData = {
|
||||
companyId: currentCompany.id,
|
||||
partnerId: data.partnerId,
|
||||
quotationDate: data.quotationDate,
|
||||
validityDate: data.validityDate || undefined,
|
||||
currencyId: data.currencyId,
|
||||
notes: data.notes || undefined,
|
||||
termsConditions: data.termsConditions || undefined,
|
||||
};
|
||||
|
||||
const newQuotation = await createQuotation(quotationData);
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion creada',
|
||||
message: `La cotizacion ${newQuotation.quotationNumber} ha sido creada exitosamente.`,
|
||||
});
|
||||
|
||||
navigate(`/sales/quotations/${newQuotation.id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al crear la cotizacion';
|
||||
setError(message);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Cotizaciones', href: '/sales/quotations' },
|
||||
{ label: 'Nueva Cotizacion' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/quotations')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Crear Cotizacion</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Complete los datos para crear una nueva cotizacion de venta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="danger" title="Error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Section 1: Customer Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Informacion del Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<Label required>Cliente</Label>
|
||||
<Controller
|
||||
name="partnerId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
options={customerOptions}
|
||||
value={field.value}
|
||||
onChange={(val) => field.onChange(val as string)}
|
||||
placeholder="Seleccionar cliente..."
|
||||
searchable
|
||||
error={!!errors.partnerId}
|
||||
disabled={loadingCustomers}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.partnerId && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.partnerId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Quotation Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5" />
|
||||
Detalles de la Cotizacion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<Label required>Fecha de Cotizacion</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('quotationDate')}
|
||||
error={!!errors.quotationDate}
|
||||
/>
|
||||
{errors.quotationDate && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.quotationDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Vigencia Hasta</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('validityDate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label required>Moneda</Label>
|
||||
<Controller
|
||||
name="currencyId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
options={mockCurrencies}
|
||||
value={field.value}
|
||||
onChange={(val) => field.onChange(val as string)}
|
||||
placeholder="Seleccionar moneda..."
|
||||
error={!!errors.currencyId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.currencyId && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.currencyId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<Label>Notas</Label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Notas adicionales para la cotizacion..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<Label>Terminos y Condiciones</Label>
|
||||
<textarea
|
||||
{...register('termsConditions')}
|
||||
rows={3}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Terminos y condiciones para el cliente..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas de la Cotizacion
|
||||
</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddLine}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Agregar Producto
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-4 h-12 w-12 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Sin productos</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Agregue productos a la cotizacion usando el boton superior
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-4"
|
||||
onClick={handleAddLine}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Agregar primer producto
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid lg:grid-cols-12 lg:gap-4 lg:border-b lg:pb-2 lg:text-sm lg:font-medium lg:text-gray-700">
|
||||
<div className="col-span-3">Producto</div>
|
||||
<div className="col-span-2">Descripcion</div>
|
||||
<div className="col-span-1 text-right">Cant.</div>
|
||||
<div className="col-span-1">Unidad</div>
|
||||
<div className="col-span-1 text-right">Precio</div>
|
||||
<div className="col-span-1 text-right">Dto. %</div>
|
||||
<div className="col-span-1 text-right">IVA %</div>
|
||||
<div className="col-span-1 text-right">Total</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
{fields.map((field, index) => {
|
||||
const line = watchLines[index];
|
||||
const lineTotals = line ? calculateLineTotals(line) : { total: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-1 gap-4 rounded-lg border bg-gray-50 p-4 lg:grid-cols-12 lg:items-center lg:bg-transparent lg:p-0 lg:border-0"
|
||||
>
|
||||
{/* Product */}
|
||||
<div className="lg:col-span-3">
|
||||
<Label className="lg:hidden">Producto</Label>
|
||||
<Controller
|
||||
name={`lines.${index}.productId`}
|
||||
control={control}
|
||||
render={({ field: selectField }) => (
|
||||
<Select
|
||||
options={mockProducts}
|
||||
value={selectField.value}
|
||||
onChange={(val) => handleProductChange(index, val as string)}
|
||||
placeholder="Buscar producto..."
|
||||
searchable
|
||||
error={!!errors.lines?.[index]?.productId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.lines?.[index]?.productId && (
|
||||
<p className="mt-1 text-xs text-danger-500">
|
||||
{errors.lines[index]?.productId?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="lg:col-span-2">
|
||||
<Label className="lg:hidden">Descripcion</Label>
|
||||
<Input
|
||||
{...register(`lines.${index}.description`)}
|
||||
placeholder="Descripcion"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Cantidad</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
{...register(`lines.${index}.quantity`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
error={!!errors.lines?.[index]?.quantity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* UOM */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Unidad</Label>
|
||||
<Controller
|
||||
name={`lines.${index}.uomId`}
|
||||
control={control}
|
||||
render={({ field: selectField }) => (
|
||||
<Select
|
||||
options={mockUoms}
|
||||
value={selectField.value}
|
||||
onChange={(val) => handleUomChange(index, val as string)}
|
||||
placeholder="UOM"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit Price */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Precio Unitario</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register(`lines.${index}.priceUnit`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
error={!!errors.lines?.[index]?.priceUnit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Discount */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Descuento %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
{...register(`lines.${index}.discount`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tax Rate */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">IVA %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
{...register(`lines.${index}.taxRate`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Total */}
|
||||
<div className="lg:col-span-1 lg:text-right">
|
||||
<Label className="lg:hidden">Total Linea</Label>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
${formatCurrency(lineTotals.total)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="lg:col-span-1 lg:text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
className="text-danger-600 hover:bg-danger-50 hover:text-danger-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{errors.lines?.root && (
|
||||
<p className="text-sm text-danger-500">{errors.lines.root.message}</p>
|
||||
)}
|
||||
{errors.lines?.message && (
|
||||
<p className="text-sm text-danger-500">{errors.lines.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 4: Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen de la Cotizacion</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-end space-y-3">
|
||||
<div className="grid w-full max-w-md grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-gray-600">Subtotal:</div>
|
||||
<div className="text-right font-medium">${formatCurrency(totals.subtotal)}</div>
|
||||
|
||||
<div className="text-gray-600">Descuento en lineas:</div>
|
||||
<div className="text-right font-medium text-danger-600">
|
||||
-${formatCurrency(totals.lineDiscount)}
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600">IVA:</div>
|
||||
<div className="text-right font-medium">${formatCurrency(totals.taxAmount)}</div>
|
||||
|
||||
<div className="border-t-2 pt-3 text-lg font-bold text-gray-900">TOTAL:</div>
|
||||
<div className="border-t-2 pt-3 text-right text-lg font-bold text-primary-600">
|
||||
${formatCurrency(totals.lineTotal)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/sales/quotations')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Crear Cotizacion
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuotationCreatePage;
|
||||
354
src/pages/sales/QuotationDetailPage.tsx
Normal file
354
src/pages/sales/QuotationDetailPage.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
User,
|
||||
Calendar,
|
||||
Building2,
|
||||
Package,
|
||||
DollarSign,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useQuotation } from '@features/sales/hooks';
|
||||
import { QuotationStatusBadge } from '@features/sales/components/QuotationStatusBadge';
|
||||
import { QuotationActions } from '@features/sales/components/QuotationActions';
|
||||
import type { QuotationLine } from '@features/sales/types';
|
||||
import { formatDate, formatNumber } from '@utils/formatters';
|
||||
|
||||
const formatCurrency = (value: number, currencyCode?: string): string => {
|
||||
const formatted = formatNumber(value, 'es-MX', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return currencyCode ? `${formatted} ${currencyCode}` : `$${formatted}`;
|
||||
};
|
||||
|
||||
export function QuotationDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { quotation, isLoading, error, refresh } = useQuotation(id || null);
|
||||
|
||||
const lineColumns: Column<QuotationLine>[] = [
|
||||
{
|
||||
key: 'product',
|
||||
header: 'Producto',
|
||||
render: (line) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{line.productName || line.productId}</div>
|
||||
<div className="text-sm text-gray-500">{line.description}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Cantidad',
|
||||
render: (line) => (
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{line.quantity} {line.uomName}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'priceUnit',
|
||||
header: 'Precio Unit.',
|
||||
render: (line) => (
|
||||
<div className="text-right font-medium">
|
||||
{formatCurrency(line.priceUnit)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'discount',
|
||||
header: 'Descuento',
|
||||
render: (line) => (
|
||||
<div className="text-right text-gray-600">
|
||||
{line.discount > 0 ? `${line.discount}%` : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tax',
|
||||
header: 'Impuesto',
|
||||
render: (line) => (
|
||||
<div className="text-right text-gray-600">
|
||||
{formatCurrency(line.amountTax)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
header: 'Total',
|
||||
render: (line) => (
|
||||
<div className="text-right font-semibold text-gray-900">
|
||||
{formatCurrency(line.amountTotal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !quotation) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Cotizacion no encontrada"
|
||||
description="No se pudo cargar la informacion de la cotizacion."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check validity
|
||||
const isExpired = quotation.validityDate && new Date(quotation.validityDate) < new Date() && quotation.status === 'sent';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Cotizaciones', href: '/sales/quotations' },
|
||||
{ label: quotation.quotationNumber },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/quotations')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100">
|
||||
<FileText className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{quotation.quotationNumber}</h1>
|
||||
<p className="text-sm text-gray-500">Cotizacion de venta</p>
|
||||
</div>
|
||||
<QuotationStatusBadge status={quotation.status as 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'} />
|
||||
{isExpired && (
|
||||
<span className="inline-flex rounded-full bg-amber-100 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
Vencida
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuotationActions quotation={quotation} onRefresh={refresh} />
|
||||
</div>
|
||||
|
||||
{/* Status Card */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<DollarSign className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(quotation.amountTotal, quotation.currencyCode)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Content - Two Columns */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Quotation Info */}
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
{/* Customer Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{quotation.partnerName || quotation.partnerId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Fechas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Fecha cotizacion</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(quotation.quotationDate, 'short')}</dd>
|
||||
</div>
|
||||
{quotation.validityDate && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Vigencia hasta</dt>
|
||||
<dd className={`text-sm font-medium ${isExpired ? 'text-red-600' : ''}`}>
|
||||
{formatDate(quotation.validityDate, 'short')}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Creada</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(quotation.createdAt, 'short')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Company Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
Empresa
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Empresa</dt>
|
||||
<dd className="text-sm font-medium">{quotation.companyName || quotation.companyId}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Moneda</dt>
|
||||
<dd className="text-sm font-medium">{quotation.currencyCode || 'MXN'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Lines & Totals */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Quotation Lines */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas de la cotizacion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{quotation.lines && quotation.lines.length > 0 ? (
|
||||
<DataTable
|
||||
data={quotation.lines}
|
||||
columns={lineColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No hay lineas en esta cotizacion
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Totals */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Subtotal</span>
|
||||
<span className="font-medium">{formatCurrency(quotation.amountUntaxed, quotation.currencyCode)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Impuestos</span>
|
||||
<span className="font-medium">{formatCurrency(quotation.amountTax, quotation.currencyCode)}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-lg font-semibold text-gray-900">Total</span>
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(quotation.amountTotal, quotation.currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{(quotation.notes || quotation.termsConditions) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notas y condiciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{quotation.notes && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Notas</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{quotation.notes}</dd>
|
||||
</div>
|
||||
)}
|
||||
{quotation.termsConditions && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Terminos y condiciones</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{quotation.termsConditions}</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Informacion del sistema
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{quotation.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Tenant ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{quotation.tenantId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Creado</dt>
|
||||
<dd className="text-sm text-gray-900">{formatDate(quotation.createdAt, 'full')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuotationDetailPage;
|
||||
780
src/pages/sales/QuotationEditPage.tsx
Normal file
780
src/pages/sales/QuotationEditPage.tsx
Normal file
@ -0,0 +1,780 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText,
|
||||
User,
|
||||
Calendar,
|
||||
Package,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useQuotation } from '@features/sales/hooks';
|
||||
import { salesApi } from '@features/sales';
|
||||
import { usePartners } from '@features/partners/hooks';
|
||||
import type { UpdateQuotationDto, CreateQuotationLineDto, UpdateQuotationLineDto } from '@features/sales/types';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
|
||||
interface LineItemFormData {
|
||||
id?: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
uomId: string;
|
||||
uomName: string;
|
||||
priceUnit: number;
|
||||
discount: number;
|
||||
isNew?: boolean;
|
||||
isModified?: boolean;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
partnerId: string;
|
||||
quotationDate: string;
|
||||
validityDate: string;
|
||||
currencyId: string;
|
||||
notes: string;
|
||||
termsConditions: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
partnerId?: string;
|
||||
quotationDate?: string;
|
||||
currencyId?: string;
|
||||
lines?: string;
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
export function QuotationEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Hooks
|
||||
const { quotation, isLoading: quotationLoading, error: quotationError, refresh } = useQuotation(id || null);
|
||||
const { partners, isLoading: partnersLoading } = usePartners({ isCustomer: true, limit: 100 });
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
partnerId: '',
|
||||
quotationDate: '',
|
||||
validityDate: '',
|
||||
currencyId: '',
|
||||
notes: '',
|
||||
termsConditions: '',
|
||||
});
|
||||
|
||||
const [lineItems, setLineItems] = useState<LineItemFormData[]>([]);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Populate form when quotation loads
|
||||
useEffect(() => {
|
||||
if (quotation) {
|
||||
const qDate = String(quotation.quotationDate || '');
|
||||
const vDate = String(quotation.validityDate || '');
|
||||
setFormData({
|
||||
partnerId: String(quotation.partnerId || ''),
|
||||
quotationDate: qDate.split('T')[0] ?? qDate,
|
||||
validityDate: vDate.split('T')[0] ?? vDate,
|
||||
currencyId: String(quotation.currencyId || ''),
|
||||
notes: String(quotation.notes || ''),
|
||||
termsConditions: String(quotation.termsConditions || ''),
|
||||
});
|
||||
|
||||
// Load existing lines
|
||||
if (quotation.lines) {
|
||||
setLineItems(
|
||||
quotation.lines.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
productName: line.productName || '',
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
uomName: line.uomName || '',
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
isNew: false,
|
||||
isModified: false,
|
||||
isDeleted: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [quotation]);
|
||||
|
||||
// Check if quotation is editable (only draft quotations can be edited)
|
||||
const isEditable = quotation?.status === 'draft';
|
||||
|
||||
// Redirect if not editable
|
||||
useEffect(() => {
|
||||
if (quotation && !isEditable) {
|
||||
showToast({
|
||||
type: 'warning',
|
||||
title: 'No editable',
|
||||
message: `La cotizacion ${quotation.quotationNumber} no puede ser editada porque su estado es "${quotation.status}".`,
|
||||
});
|
||||
navigate(`/sales/quotations/${id}`);
|
||||
}
|
||||
}, [quotation, isEditable, navigate, id, showToast]);
|
||||
|
||||
const handleFieldChange = (field: keyof FormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineChange = (index: number, field: keyof LineItemFormData, value: string | number) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentLine = updated[index];
|
||||
if (currentLine) {
|
||||
updated[index] = {
|
||||
...currentLine,
|
||||
[field]: value,
|
||||
isModified: !currentLine.isNew,
|
||||
};
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addNewLine = () => {
|
||||
setLineItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
productId: '',
|
||||
productName: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
uomId: '',
|
||||
uomName: '',
|
||||
priceUnit: 0,
|
||||
discount: 0,
|
||||
isNew: true,
|
||||
isModified: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const markLineForDeletion = (index: number) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentLine = updated[index];
|
||||
if (currentLine?.isNew) {
|
||||
// Remove new lines immediately
|
||||
return updated.filter((_, i) => i !== index);
|
||||
}
|
||||
// Mark existing lines for deletion
|
||||
if (currentLine) {
|
||||
updated[index] = { ...currentLine, isDeleted: true };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const restoreLine = (index: number) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentLine = updated[index];
|
||||
if (currentLine) {
|
||||
updated[index] = { ...currentLine, isDeleted: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const calculateLineTotal = (line: LineItemFormData): number => {
|
||||
const subtotal = line.quantity * line.priceUnit;
|
||||
const discountAmount = subtotal * (line.discount / 100);
|
||||
return subtotal - discountAmount;
|
||||
};
|
||||
|
||||
const calculateTotals = () => {
|
||||
const activeLines = lineItems.filter((l) => !l.isDeleted);
|
||||
const subtotal = activeLines.reduce((sum, line) => sum + calculateLineTotal(line), 0);
|
||||
// For simplicity, estimate tax at 16% (IVA Mexico)
|
||||
const taxRate = 0.16;
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
return { subtotal, tax, total };
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.partnerId) {
|
||||
newErrors.partnerId = 'El cliente es requerido';
|
||||
}
|
||||
if (!formData.quotationDate) {
|
||||
newErrors.quotationDate = 'La fecha de cotizacion es requerida';
|
||||
}
|
||||
if (!formData.currencyId) {
|
||||
newErrors.currencyId = 'La moneda es requerida';
|
||||
}
|
||||
|
||||
const activeLines = lineItems.filter((l) => !l.isDeleted);
|
||||
if (activeLines.length === 0) {
|
||||
newErrors.lines = 'Debe agregar al menos una linea de cotizacion';
|
||||
} else {
|
||||
const invalidLine = activeLines.find(
|
||||
(l) => !l.productId || l.quantity <= 0 || l.priceUnit < 0
|
||||
);
|
||||
if (invalidLine) {
|
||||
newErrors.lines = 'Todas las lineas deben tener producto, cantidad y precio validos';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
// 1. Update quotation header
|
||||
const quotationUpdate: UpdateQuotationDto = {
|
||||
partnerId: formData.partnerId,
|
||||
quotationDate: formData.quotationDate,
|
||||
validityDate: formData.validityDate || null,
|
||||
currencyId: formData.currencyId,
|
||||
notes: formData.notes || null,
|
||||
termsConditions: formData.termsConditions || null,
|
||||
};
|
||||
|
||||
await salesApi.updateQuotation(id, quotationUpdate);
|
||||
|
||||
// 2. Process line items
|
||||
const linesToDelete = lineItems.filter((l) => l.isDeleted && l.id);
|
||||
const linesToUpdate = lineItems.filter((l) => l.isModified && l.id && !l.isDeleted);
|
||||
const linesToAdd = lineItems.filter((l) => l.isNew && !l.isDeleted);
|
||||
|
||||
// Delete lines
|
||||
for (const line of linesToDelete) {
|
||||
if (line.id) {
|
||||
await salesApi.removeQuotationLine(id, line.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing lines
|
||||
for (const line of linesToUpdate) {
|
||||
if (line.id) {
|
||||
const updateData: UpdateQuotationLineDto = {
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
};
|
||||
await salesApi.updateQuotationLine(id, line.id, updateData);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new lines
|
||||
for (const line of linesToAdd) {
|
||||
const createData: CreateQuotationLineDto = {
|
||||
productId: line.productId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
};
|
||||
await salesApi.addQuotationLine(id, createData);
|
||||
}
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Cotizacion actualizada',
|
||||
message: 'Los cambios han sido guardados exitosamente.',
|
||||
});
|
||||
|
||||
navigate(`/sales/quotations/${id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al actualizar la cotizacion';
|
||||
setSubmitError(message);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (quotationLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (quotationError || !quotation) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Cotizacion no encontrada"
|
||||
description="No se pudo cargar la informacion de la cotizacion."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Cotizaciones', href: '/sales/quotations' },
|
||||
{ label: quotation.quotationNumber, href: `/sales/quotations/${id}` },
|
||||
{ label: 'Editar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/sales/quotations/${id}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Editar Cotizacion {quotation.quotationNumber}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Modifica la informacion de la cotizacion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Customer Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Informacion del Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cliente *
|
||||
</label>
|
||||
<select
|
||||
value={formData.partnerId}
|
||||
onChange={(e) => handleFieldChange('partnerId', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.partnerId
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
disabled={partnersLoading}
|
||||
>
|
||||
<option value="">Seleccionar cliente...</option>
|
||||
{partners.map((partner) => (
|
||||
<option key={partner.id} value={partner.id}>
|
||||
{partner.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.partnerId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.partnerId}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quotation Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Detalles de la Cotizacion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha de Cotizacion *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.quotationDate}
|
||||
onChange={(e) => handleFieldChange('quotationDate', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.quotationDate
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.quotationDate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.quotationDate}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Vigencia Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validityDate}
|
||||
onChange={(e) => handleFieldChange('validityDate', e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Moneda *
|
||||
</label>
|
||||
<select
|
||||
value={formData.currencyId}
|
||||
onChange={(e) => handleFieldChange('currencyId', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.currencyId
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar moneda...</option>
|
||||
<option value="MXN">MXN - Peso Mexicano</option>
|
||||
<option value="USD">USD - Dolar Americano</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
</select>
|
||||
{errors.currencyId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas de la Cotizacion
|
||||
</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addNewLine}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Agregar Linea
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errors.lines && (
|
||||
<Alert variant="danger" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{errors.lines}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{lineItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<p className="mt-2">No hay lineas en la cotizacion</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={addNewLine}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Agregar primera linea
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 font-medium">Producto</th>
|
||||
<th className="pb-2 font-medium">Descripcion</th>
|
||||
<th className="pb-2 font-medium text-right">Cantidad</th>
|
||||
<th className="pb-2 font-medium text-right">Precio</th>
|
||||
<th className="pb-2 font-medium text-right">Desc %</th>
|
||||
<th className="pb-2 font-medium text-right">Total</th>
|
||||
<th className="pb-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((line, index) => (
|
||||
<tr
|
||||
key={line.id || `new-${index}`}
|
||||
className={`border-b ${line.isDeleted ? 'bg-red-50 opacity-50' : ''}`}
|
||||
>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={line.productName || line.productId}
|
||||
onChange={(e) => handleLineChange(index, 'productId', e.target.value)}
|
||||
placeholder="ID Producto"
|
||||
disabled={line.isDeleted}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={line.description}
|
||||
onChange={(e) => handleLineChange(index, 'description', e.target.value)}
|
||||
placeholder="Descripcion"
|
||||
disabled={line.isDeleted}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => handleLineChange(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-20 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.priceUnit}
|
||||
onChange={(e) => handleLineChange(index, 'priceUnit', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.discount}
|
||||
onChange={(e) => handleLineChange(index, 'discount', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-16 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium">
|
||||
${formatCurrency(calculateLineTotal(line))}
|
||||
</td>
|
||||
<td className="py-2 pl-2">
|
||||
{line.isDeleted ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => restoreLine(index)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Restaurar
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markLineForDeletion(index)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Notas y Terminos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleFieldChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Notas internas sobre la cotizacion..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Terminos y Condiciones
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.termsConditions}
|
||||
onChange={(e) => handleFieldChange('termsConditions', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Terminos y condiciones para el cliente..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Summary */}
|
||||
<div className="space-y-6">
|
||||
{/* Submit Error */}
|
||||
{submitError && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error al guardar"
|
||||
onClose={() => setSubmitError(null)}
|
||||
>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Quotation Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Resumen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Subtotal</span>
|
||||
<span>${formatCurrency(totals.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">IVA (16%)</span>
|
||||
<span>${formatCurrency(totals.tax)}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Total</span>
|
||||
<span className="text-lg">${formatCurrency(totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{lineItems.filter((l) => !l.isDeleted).length} linea(s) activa(s)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !hasChanges}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => navigate(`/sales/quotations/${id}`)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
{!hasChanges && (
|
||||
<p className="mt-3 text-center text-xs text-gray-400">
|
||||
No hay cambios para guardar
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Info */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
|
||||
Estado: Borrador
|
||||
</span>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Solo las cotizaciones en estado borrador pueden ser editadas
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuotationEditPage;
|
||||
@ -283,7 +283,7 @@ export function QuotationsPage() {
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button>
|
||||
<Button onClick={() => navigate('/sales/quotations/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nueva cotizacion
|
||||
</Button>
|
||||
|
||||
782
src/pages/sales/SalesOrderCreatePage.tsx
Normal file
782
src/pages/sales/SalesOrderCreatePage.tsx
Normal file
@ -0,0 +1,782 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Calculator,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Input } from '@components/atoms/Input';
|
||||
import { Label } from '@components/atoms/Label';
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Select, type SelectOption } from '@components/organisms/Select';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { useSalesOrders } from '@features/sales/hooks';
|
||||
import { useCustomers } from '@features/partners/hooks';
|
||||
import { useWarehouses } from '@features/inventory/hooks';
|
||||
import { useCompanyStore } from '@stores/useCompanyStore';
|
||||
import { formatNumber } from '@utils/formatters';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
interface LineItem {
|
||||
id: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
uomId: string;
|
||||
uomName: string;
|
||||
priceUnit: number;
|
||||
discount: number;
|
||||
taxRate: number;
|
||||
taxIds: string[];
|
||||
}
|
||||
|
||||
// ==================== Validation Schema ====================
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
id: z.string(),
|
||||
productId: z.string().min(1, 'Producto requerido'),
|
||||
productName: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number().min(0.01, 'Cantidad debe ser mayor a 0'),
|
||||
uomId: z.string().min(1, 'Unidad requerida'),
|
||||
uomName: z.string(),
|
||||
priceUnit: z.number().min(0, 'Precio no puede ser negativo'),
|
||||
discount: z.number().min(0).max(100, 'Descuento debe estar entre 0 y 100'),
|
||||
taxRate: z.number().min(0).max(100),
|
||||
taxIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const salesOrderSchema = z.object({
|
||||
partnerId: z.string().min(1, 'Cliente es requerido'),
|
||||
billingStreet: z.string().optional(),
|
||||
billingCity: z.string().optional(),
|
||||
billingState: z.string().optional(),
|
||||
billingZip: z.string().optional(),
|
||||
billingCountry: z.string().optional(),
|
||||
sameAsShipping: z.boolean(),
|
||||
shippingStreet: z.string().optional(),
|
||||
shippingCity: z.string().optional(),
|
||||
shippingState: z.string().optional(),
|
||||
shippingZip: z.string().optional(),
|
||||
shippingCountry: z.string().optional(),
|
||||
orderDate: z.string().min(1, 'Fecha de pedido requerida'),
|
||||
commitmentDate: z.string().optional(),
|
||||
warehouseId: z.string().optional(),
|
||||
currencyId: z.string().min(1, 'Moneda requerida'),
|
||||
notes: z.string().optional(),
|
||||
discountAmount: z.number().min(0).optional(),
|
||||
shippingAmount: z.number().min(0).optional(),
|
||||
lines: z.array(lineItemSchema).min(1, 'Debe agregar al menos un producto'),
|
||||
});
|
||||
|
||||
type SalesOrderFormData = z.infer<typeof salesOrderSchema>;
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
const generateTempId = () => `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return formatNumber(value, 'es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
// Mock products for demo (should come from products API)
|
||||
const mockProducts: SelectOption[] = [
|
||||
{ value: 'prod-001', label: 'Producto A - SKU001' },
|
||||
{ value: 'prod-002', label: 'Producto B - SKU002' },
|
||||
{ value: 'prod-003', label: 'Producto C - SKU003' },
|
||||
{ value: 'prod-004', label: 'Servicio Premium' },
|
||||
{ value: 'prod-005', label: 'Licencia Software Anual' },
|
||||
];
|
||||
|
||||
// Mock UOMs (should come from UOM API)
|
||||
const mockUoms: SelectOption[] = [
|
||||
{ value: 'uom-001', label: 'Unidad' },
|
||||
{ value: 'uom-002', label: 'Kg' },
|
||||
{ value: 'uom-003', label: 'Litro' },
|
||||
{ value: 'uom-004', label: 'Hora' },
|
||||
{ value: 'uom-005', label: 'Caja' },
|
||||
];
|
||||
|
||||
// Mock currencies (should come from currency API)
|
||||
const mockCurrencies: SelectOption[] = [
|
||||
{ value: 'curr-001', label: 'MXN - Peso Mexicano' },
|
||||
{ value: 'curr-002', label: 'USD - Dolar Estadounidense' },
|
||||
{ value: 'curr-003', label: 'EUR - Euro' },
|
||||
];
|
||||
|
||||
// Default tax rate (16% IVA for Mexico)
|
||||
const DEFAULT_TAX_RATE = 16;
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
export function SalesOrderCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { currentCompany } = useCompanyStore();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// API hooks
|
||||
const { createOrder } = useSalesOrders({ autoFetch: false });
|
||||
const { partners: customers, isLoading: loadingCustomers } = useCustomers({ limit: 100 });
|
||||
const { warehouses, isLoading: loadingWarehouses } = useWarehouses();
|
||||
|
||||
// Form setup
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<SalesOrderFormData>({
|
||||
resolver: zodResolver(salesOrderSchema),
|
||||
defaultValues: {
|
||||
partnerId: '',
|
||||
billingStreet: '',
|
||||
billingCity: '',
|
||||
billingState: '',
|
||||
billingZip: '',
|
||||
billingCountry: 'Mexico',
|
||||
sameAsShipping: true,
|
||||
shippingStreet: '',
|
||||
shippingCity: '',
|
||||
shippingState: '',
|
||||
shippingZip: '',
|
||||
shippingCountry: 'Mexico',
|
||||
orderDate: new Date().toISOString().split('T')[0],
|
||||
commitmentDate: '',
|
||||
warehouseId: '',
|
||||
currencyId: 'curr-001',
|
||||
notes: '',
|
||||
discountAmount: 0,
|
||||
shippingAmount: 0,
|
||||
lines: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'lines',
|
||||
});
|
||||
|
||||
// Watch for calculations
|
||||
const watchLines = watch('lines');
|
||||
const watchSameAsShipping = watch('sameAsShipping');
|
||||
const watchDiscountAmount = watch('discountAmount') || 0;
|
||||
const watchShippingAmount = watch('shippingAmount') || 0;
|
||||
|
||||
// Calculate totals
|
||||
const calculateLineTotals = useCallback((line: LineItem) => {
|
||||
const subtotal = line.quantity * line.priceUnit;
|
||||
const discountAmt = subtotal * (line.discount / 100);
|
||||
const afterDiscount = subtotal - discountAmt;
|
||||
const taxAmt = afterDiscount * (line.taxRate / 100);
|
||||
const total = afterDiscount + taxAmt;
|
||||
return { subtotal, discountAmt, afterDiscount, taxAmt, total };
|
||||
}, []);
|
||||
|
||||
const totals = watchLines.reduce(
|
||||
(acc, line) => {
|
||||
const lineTotals = calculateLineTotals(line);
|
||||
return {
|
||||
subtotal: acc.subtotal + lineTotals.subtotal,
|
||||
lineDiscount: acc.lineDiscount + lineTotals.discountAmt,
|
||||
taxAmount: acc.taxAmount + lineTotals.taxAmt,
|
||||
lineTotal: acc.lineTotal + lineTotals.total,
|
||||
};
|
||||
},
|
||||
{ subtotal: 0, lineDiscount: 0, taxAmount: 0, lineTotal: 0 }
|
||||
);
|
||||
|
||||
const grandTotal = totals.lineTotal - watchDiscountAmount + watchShippingAmount;
|
||||
|
||||
// Build customer options
|
||||
const customerOptions: SelectOption[] = customers.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}));
|
||||
|
||||
// Build warehouse options
|
||||
const warehouseOptions: SelectOption[] = warehouses.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.name,
|
||||
}));
|
||||
|
||||
// Add new line item
|
||||
const handleAddLine = () => {
|
||||
append({
|
||||
id: generateTempId(),
|
||||
productId: '',
|
||||
productName: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
uomId: 'uom-001',
|
||||
uomName: 'Unidad',
|
||||
priceUnit: 0,
|
||||
discount: 0,
|
||||
taxRate: DEFAULT_TAX_RATE,
|
||||
taxIds: [],
|
||||
});
|
||||
};
|
||||
|
||||
// Handle product selection for a line
|
||||
const handleProductChange = (index: number, productId: string) => {
|
||||
const product = mockProducts.find((p) => p.value === productId);
|
||||
if (product) {
|
||||
setValue(`lines.${index}.productId`, productId);
|
||||
setValue(`lines.${index}.productName`, product.label);
|
||||
setValue(`lines.${index}.description`, product.label);
|
||||
// In real implementation, fetch product price from API
|
||||
setValue(`lines.${index}.priceUnit`, Math.random() * 1000 + 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle UOM selection for a line
|
||||
const handleUomChange = (index: number, uomId: string) => {
|
||||
const uom = mockUoms.find((u) => u.value === uomId);
|
||||
if (uom) {
|
||||
setValue(`lines.${index}.uomId`, uomId);
|
||||
setValue(`lines.${index}.uomName`, uom.label);
|
||||
}
|
||||
};
|
||||
|
||||
// Submit handler
|
||||
const onSubmit = async (data: SalesOrderFormData) => {
|
||||
if (!currentCompany) {
|
||||
setError('No hay una empresa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const orderData = {
|
||||
companyId: currentCompany.id,
|
||||
partnerId: data.partnerId,
|
||||
orderDate: data.orderDate,
|
||||
commitmentDate: data.commitmentDate || undefined,
|
||||
currencyId: data.currencyId,
|
||||
notes: data.notes || undefined,
|
||||
};
|
||||
|
||||
const newOrder = await createOrder(orderData);
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Pedido creado',
|
||||
message: `El pedido ${newOrder.name} ha sido creado exitosamente.`,
|
||||
});
|
||||
|
||||
navigate(`/sales/orders/${newOrder.id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al crear el pedido';
|
||||
setError(message);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Pedidos', href: '/sales/orders' },
|
||||
{ label: 'Nuevo Pedido' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/orders')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Crear Pedido de Venta</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Complete los datos para crear un nuevo pedido de venta
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="danger" title="Error" onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Section 1: Customer Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Informacion del Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<Label required>Cliente</Label>
|
||||
<Controller
|
||||
name="partnerId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
options={customerOptions}
|
||||
value={field.value}
|
||||
onChange={(val) => field.onChange(val as string)}
|
||||
placeholder="Seleccionar cliente..."
|
||||
searchable
|
||||
error={!!errors.partnerId}
|
||||
disabled={loadingCustomers}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.partnerId && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.partnerId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Address */}
|
||||
<div>
|
||||
<h4 className="mb-3 font-medium text-gray-700">Direccion de Facturacion</h4>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<Label>Calle y numero</Label>
|
||||
<Input {...register('billingStreet')} placeholder="Av. Principal #123" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Ciudad</Label>
|
||||
<Input {...register('billingCity')} placeholder="Ciudad" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Estado/Provincia</Label>
|
||||
<Input {...register('billingState')} placeholder="Estado" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Codigo Postal</Label>
|
||||
<Input {...register('billingZip')} placeholder="12345" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Pais</Label>
|
||||
<Input {...register('billingCountry')} placeholder="Mexico" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Address */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<h4 className="font-medium text-gray-700">Direccion de Envio</h4>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('sameAsShipping')}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
Igual que facturacion
|
||||
</label>
|
||||
</div>
|
||||
{!watchSameAsShipping && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<Label>Calle y numero</Label>
|
||||
<Input {...register('shippingStreet')} placeholder="Av. Principal #123" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Ciudad</Label>
|
||||
<Input {...register('shippingCity')} placeholder="Ciudad" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Estado/Provincia</Label>
|
||||
<Input {...register('shippingState')} placeholder="Estado" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Codigo Postal</Label>
|
||||
<Input {...register('shippingZip')} placeholder="12345" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Pais</Label>
|
||||
<Input {...register('shippingCountry')} placeholder="Mexico" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 2: Order Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5" />
|
||||
Detalles del Pedido
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<Label required>Fecha del Pedido</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('orderDate')}
|
||||
error={!!errors.orderDate}
|
||||
/>
|
||||
{errors.orderDate && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.orderDate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>Fecha de Entrega Solicitada</Label>
|
||||
<Input
|
||||
type="date"
|
||||
{...register('commitmentDate')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Almacen</Label>
|
||||
<Controller
|
||||
name="warehouseId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
options={warehouseOptions}
|
||||
value={field.value}
|
||||
onChange={(val) => field.onChange(val as string)}
|
||||
placeholder="Seleccionar almacen..."
|
||||
clearable
|
||||
disabled={loadingWarehouses}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label required>Moneda</Label>
|
||||
<Controller
|
||||
name="currencyId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
options={mockCurrencies}
|
||||
value={field.value}
|
||||
onChange={(val) => field.onChange(val as string)}
|
||||
placeholder="Seleccionar moneda..."
|
||||
error={!!errors.currencyId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.currencyId && (
|
||||
<p className="mt-1 text-sm text-danger-500">{errors.currencyId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 lg:col-span-3">
|
||||
<Label>Notas</Label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
placeholder="Notas adicionales para el pedido..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas del Pedido
|
||||
</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleAddLine}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Agregar Producto
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fields.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="mb-4 h-12 w-12 text-gray-300" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Sin productos</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Agregue productos al pedido usando el boton superior
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-4"
|
||||
onClick={handleAddLine}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Agregar primer producto
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Desktop Table Header */}
|
||||
<div className="hidden lg:grid lg:grid-cols-12 lg:gap-4 lg:border-b lg:pb-2 lg:text-sm lg:font-medium lg:text-gray-700">
|
||||
<div className="col-span-3">Producto</div>
|
||||
<div className="col-span-2">Descripcion</div>
|
||||
<div className="col-span-1 text-right">Cant.</div>
|
||||
<div className="col-span-1">Unidad</div>
|
||||
<div className="col-span-1 text-right">Precio</div>
|
||||
<div className="col-span-1 text-right">Dto. %</div>
|
||||
<div className="col-span-1 text-right">IVA %</div>
|
||||
<div className="col-span-1 text-right">Total</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
|
||||
{/* Line Items */}
|
||||
{fields.map((field, index) => {
|
||||
const line = watchLines[index];
|
||||
const lineTotals = line ? calculateLineTotals(line) : { total: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className="grid grid-cols-1 gap-4 rounded-lg border bg-gray-50 p-4 lg:grid-cols-12 lg:items-center lg:bg-transparent lg:p-0 lg:border-0"
|
||||
>
|
||||
{/* Product */}
|
||||
<div className="lg:col-span-3">
|
||||
<Label className="lg:hidden">Producto</Label>
|
||||
<Controller
|
||||
name={`lines.${index}.productId`}
|
||||
control={control}
|
||||
render={({ field: selectField }) => (
|
||||
<Select
|
||||
options={mockProducts}
|
||||
value={selectField.value}
|
||||
onChange={(val) => handleProductChange(index, val as string)}
|
||||
placeholder="Buscar producto..."
|
||||
searchable
|
||||
error={!!errors.lines?.[index]?.productId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.lines?.[index]?.productId && (
|
||||
<p className="mt-1 text-xs text-danger-500">
|
||||
{errors.lines[index]?.productId?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="lg:col-span-2">
|
||||
<Label className="lg:hidden">Descripcion</Label>
|
||||
<Input
|
||||
{...register(`lines.${index}.description`)}
|
||||
placeholder="Descripcion"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Cantidad</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
{...register(`lines.${index}.quantity`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
error={!!errors.lines?.[index]?.quantity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* UOM */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Unidad</Label>
|
||||
<Controller
|
||||
name={`lines.${index}.uomId`}
|
||||
control={control}
|
||||
render={({ field: selectField }) => (
|
||||
<Select
|
||||
options={mockUoms}
|
||||
value={selectField.value}
|
||||
onChange={(val) => handleUomChange(index, val as string)}
|
||||
placeholder="UOM"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Unit Price */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Precio Unitario</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register(`lines.${index}.priceUnit`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
error={!!errors.lines?.[index]?.priceUnit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Discount */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">Descuento %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
{...register(`lines.${index}.discount`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tax Rate */}
|
||||
<div className="lg:col-span-1">
|
||||
<Label className="lg:hidden">IVA %</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
{...register(`lines.${index}.taxRate`, { valueAsNumber: true })}
|
||||
className="text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Line Total */}
|
||||
<div className="lg:col-span-1 lg:text-right">
|
||||
<Label className="lg:hidden">Total Linea</Label>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
${formatCurrency(lineTotals.total)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="lg:col-span-1 lg:text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
className="text-danger-600 hover:bg-danger-50 hover:text-danger-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{errors.lines?.root && (
|
||||
<p className="text-sm text-danger-500">{errors.lines.root.message}</p>
|
||||
)}
|
||||
{errors.lines?.message && (
|
||||
<p className="text-sm text-danger-500">{errors.lines.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Section 4: Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen del Pedido</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-end space-y-3">
|
||||
<div className="grid w-full max-w-md grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-gray-600">Subtotal:</div>
|
||||
<div className="text-right font-medium">${formatCurrency(totals.subtotal)}</div>
|
||||
|
||||
<div className="text-gray-600">Descuento en lineas:</div>
|
||||
<div className="text-right font-medium text-danger-600">
|
||||
-${formatCurrency(totals.lineDiscount)}
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600">IVA:</div>
|
||||
<div className="text-right font-medium">${formatCurrency(totals.taxAmount)}</div>
|
||||
|
||||
<div className="border-t pt-2 text-gray-600">Subtotal con IVA:</div>
|
||||
<div className="border-t pt-2 text-right font-medium">
|
||||
${formatCurrency(totals.lineTotal)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<span>Descuento adicional:</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register('discountAmount', { valueAsNumber: true })}
|
||||
className="w-32 text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600">
|
||||
<span>Envio:</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
{...register('shippingAmount', { valueAsNumber: true })}
|
||||
className="w-32 text-right"
|
||||
inputSize="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t-2 pt-3 text-lg font-bold text-gray-900">TOTAL:</div>
|
||||
<div className="border-t-2 pt-3 text-right text-lg font-bold text-primary-600">
|
||||
${formatCurrency(grandTotal)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/sales/orders')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting}>
|
||||
Crear Pedido
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SalesOrderCreatePage;
|
||||
726
src/pages/sales/SalesOrderDetailPage.tsx
Normal file
726
src/pages/sales/SalesOrderDetailPage.tsx
Normal file
@ -0,0 +1,726 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
ShoppingCart,
|
||||
User,
|
||||
Calendar,
|
||||
Building2,
|
||||
Package,
|
||||
Truck,
|
||||
DollarSign,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { ConfirmModal } from '@components/organisms/Modal';
|
||||
import { DataTable, type Column } from '@components/organisms/DataTable';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useSalesOrder, salesApi } from '@features/sales';
|
||||
import type {
|
||||
SalesOrderLine,
|
||||
SalesOrderStatus,
|
||||
InvoiceStatus,
|
||||
DeliveryStatus,
|
||||
} from '@features/sales/types';
|
||||
import { formatDate, formatNumber } from '@utils/formatters';
|
||||
|
||||
const statusLabels: Record<SalesOrderStatus, string> = {
|
||||
draft: 'Borrador',
|
||||
sent: 'Enviado',
|
||||
sale: 'Confirmado',
|
||||
done: 'Completado',
|
||||
cancelled: 'Cancelado',
|
||||
};
|
||||
|
||||
const statusColors: Record<SalesOrderStatus, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
sent: 'bg-blue-100 text-blue-700',
|
||||
sale: 'bg-green-100 text-green-700',
|
||||
done: 'bg-purple-100 text-purple-700',
|
||||
cancelled: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const invoiceStatusLabels: Record<InvoiceStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
partial: 'Parcial',
|
||||
invoiced: 'Facturado',
|
||||
};
|
||||
|
||||
const invoiceStatusColors: Record<InvoiceStatus, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
partial: 'bg-blue-100 text-blue-700',
|
||||
invoiced: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
const deliveryStatusLabels: Record<DeliveryStatus, string> = {
|
||||
pending: 'Pendiente',
|
||||
partial: 'Parcial',
|
||||
delivered: 'Entregado',
|
||||
};
|
||||
|
||||
const deliveryStatusColors: Record<DeliveryStatus, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
partial: 'bg-blue-100 text-blue-700',
|
||||
delivered: 'bg-green-100 text-green-700',
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number, currencyCode?: string): string => {
|
||||
const formatted = formatNumber(value, 'es-MX', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return currencyCode ? `${formatted} ${currencyCode}` : `$${formatted}`;
|
||||
};
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: SalesOrderStatus;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-sm font-medium ${statusColors[status]}`}
|
||||
>
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface InvoiceStatusBadgeProps {
|
||||
status: InvoiceStatus;
|
||||
}
|
||||
|
||||
function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${invoiceStatusColors[status]}`}
|
||||
>
|
||||
{invoiceStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeliveryStatusBadgeProps {
|
||||
status: DeliveryStatus;
|
||||
}
|
||||
|
||||
function DeliveryStatusBadge({ status }: DeliveryStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${deliveryStatusColors[status]}`}
|
||||
>
|
||||
{deliveryStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalesOrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { order, isLoading, error, refresh } = useSalesOrder(id || null);
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [showCancelModal, setShowCancelModal] = useState(false);
|
||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await salesApi.deleteOrder(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Pedido eliminado',
|
||||
message: 'El pedido ha sido eliminado exitosamente.',
|
||||
});
|
||||
navigate('/sales/orders');
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo eliminar el pedido.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowDeleteModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await salesApi.confirmOrder(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Pedido confirmado',
|
||||
message: 'El pedido ha sido confirmado exitosamente.',
|
||||
});
|
||||
refresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo confirmar el pedido.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowConfirmModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await salesApi.cancelOrder(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Pedido cancelado',
|
||||
message: 'El pedido ha sido cancelado.',
|
||||
});
|
||||
refresh();
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo cancelar el pedido.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowCancelModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
if (!id) return;
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const result = await salesApi.createInvoice(id);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Factura creada',
|
||||
message: 'La factura ha sido creada exitosamente.',
|
||||
});
|
||||
refresh();
|
||||
if (result.invoiceId) {
|
||||
navigate(`/accounting/invoices/${result.invoiceId}`);
|
||||
}
|
||||
} catch {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message: 'No se pudo crear la factura.',
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setShowInvoiceModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lineColumns: Column<SalesOrderLine>[] = [
|
||||
{
|
||||
key: 'product',
|
||||
header: 'Producto',
|
||||
render: (line) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{line.productName || line.productId}</div>
|
||||
<div className="text-sm text-gray-500">{line.description}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quantity',
|
||||
header: 'Cantidad',
|
||||
render: (line) => (
|
||||
<div className="text-right">
|
||||
<div className="font-medium">{line.quantity} {line.uomName}</div>
|
||||
{line.qtyDelivered > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Entregado: {line.qtyDelivered}
|
||||
</div>
|
||||
)}
|
||||
{line.qtyInvoiced > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Facturado: {line.qtyInvoiced}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'priceUnit',
|
||||
header: 'Precio Unit.',
|
||||
render: (line) => (
|
||||
<div className="text-right font-medium">
|
||||
{formatCurrency(line.priceUnit)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'discount',
|
||||
header: 'Descuento',
|
||||
render: (line) => (
|
||||
<div className="text-right text-gray-600">
|
||||
{line.discount > 0 ? `${line.discount}%` : '-'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tax',
|
||||
header: 'Impuesto',
|
||||
render: (line) => (
|
||||
<div className="text-right text-gray-600">
|
||||
{formatCurrency(line.amountTax)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
header: 'Total',
|
||||
render: (line) => (
|
||||
<div className="text-right font-semibold text-gray-900">
|
||||
{formatCurrency(line.amountTotal)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const canEdit = order?.status === 'draft';
|
||||
const canConfirm = order?.status === 'draft';
|
||||
const canCancel = order?.status === 'draft' || order?.status === 'sent';
|
||||
const canCreateInvoice = order?.status === 'sale' && order?.invoiceStatus !== 'invoiced';
|
||||
const canDelete = order?.status === 'draft';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Pedido no encontrado"
|
||||
description="No se pudo cargar la informacion del pedido de venta."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Pedidos', href: '/sales/orders' },
|
||||
{ label: order.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate('/sales/orders')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100">
|
||||
<ShoppingCart className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{order.name}</h1>
|
||||
{order.clientOrderRef && (
|
||||
<p className="text-sm text-gray-500">Ref. Cliente: {order.clientOrderRef}</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={refresh} disabled={isProcessing}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isProcessing ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
{canEdit && (
|
||||
<Button variant="outline" onClick={() => navigate(`/sales/orders/${id}/edit`)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canConfirm && (
|
||||
<Button variant="primary" onClick={() => setShowConfirmModal(true)}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Confirmar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCreateInvoice && (
|
||||
<Button onClick={() => setShowInvoiceModal(true)}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Crear Factura
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCancel && (
|
||||
<Button variant="outline" onClick={() => setShowCancelModal(true)}>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="danger" onClick={() => setShowDeleteModal(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Eliminar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Facturacion</div>
|
||||
<InvoiceStatusBadge status={order.invoiceStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Truck className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Entrega</div>
|
||||
<DeliveryStatusBadge status={order.deliveryStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
|
||||
<DollarSign className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(order.amountTotal, order.currencyCode)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Two Columns */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Left Column - Order Info */}
|
||||
<div className="space-y-6 lg:col-span-1">
|
||||
{/* Customer Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{order.partnerName || order.partnerId}</div>
|
||||
</div>
|
||||
{order.salesTeamName && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span>{order.salesTeamName}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.userName && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<User className="h-4 w-4" />
|
||||
<span>Vendedor: {order.userName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Fechas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Fecha pedido</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(order.orderDate, 'short')}</dd>
|
||||
</div>
|
||||
{order.validityDate && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Vigencia</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(order.validityDate, 'short')}</dd>
|
||||
</div>
|
||||
)}
|
||||
{order.commitmentDate && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Fecha compromiso</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(order.commitmentDate, 'short')}</dd>
|
||||
</div>
|
||||
)}
|
||||
{order.confirmedAt && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Confirmado</dt>
|
||||
<dd className="text-sm font-medium">{formatDate(order.confirmedAt, 'short')}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment & Pricing */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Condiciones
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Moneda</dt>
|
||||
<dd className="text-sm font-medium">{order.currencyCode || 'MXN'}</dd>
|
||||
</div>
|
||||
{order.pricelistName && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Lista de precios</dt>
|
||||
<dd className="text-sm font-medium">{order.pricelistName}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-500">Politica facturacion</dt>
|
||||
<dd className="text-sm font-medium">
|
||||
{order.invoicePolicy === 'order' ? 'Al confirmar' : 'Al entregar'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Company Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5" />
|
||||
Empresa
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Empresa</dt>
|
||||
<dd className="text-sm font-medium">{order.companyName || order.companyId}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Lines & Totals */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Order Lines */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas del pedido
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{order.lines && order.lines.length > 0 ? (
|
||||
<DataTable
|
||||
data={order.lines}
|
||||
columns={lineColumns}
|
||||
isLoading={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
No hay lineas en este pedido
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Totals */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resumen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Subtotal</span>
|
||||
<span className="font-medium">{formatCurrency(order.amountUntaxed, order.currencyCode)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Impuestos</span>
|
||||
<span className="font-medium">{formatCurrency(order.amountTax, order.currencyCode)}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-lg font-semibold text-gray-900">Total</span>
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(order.amountTotal, order.currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{(order.notes || order.termsConditions) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notas y condiciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{order.notes && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Notas</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{order.notes}</dd>
|
||||
</div>
|
||||
)}
|
||||
{order.termsConditions && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Terminos y condiciones</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-sm text-gray-900">{order.termsConditions}</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Informacion del sistema
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{order.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Tenant ID</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{order.tenantId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">Creado</dt>
|
||||
<dd className="text-sm text-gray-900">{formatDate(order.createdAt, 'full')}</dd>
|
||||
</div>
|
||||
{order.pickingId && (
|
||||
<div>
|
||||
<dt className="text-sm text-gray-500">ID Entrega</dt>
|
||||
<dd className="font-mono text-sm text-gray-900">{order.pickingId}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Eliminar pedido"
|
||||
message={`¿Estas seguro de que deseas eliminar el pedido ${order.name}? Esta accion no se puede deshacer.`}
|
||||
variant="danger"
|
||||
confirmText="Eliminar"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
title="Confirmar pedido"
|
||||
message={`¿Confirmar el pedido ${order.name}? Esta accion reservara el inventario y el pedido pasara a estado Confirmado.`}
|
||||
variant="success"
|
||||
confirmText="Confirmar"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showCancelModal}
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
onConfirm={handleCancel}
|
||||
title="Cancelar pedido"
|
||||
message={`¿Cancelar el pedido ${order.name}? Esta accion no se puede deshacer.`}
|
||||
variant="warning"
|
||||
confirmText="Cancelar pedido"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={showInvoiceModal}
|
||||
onClose={() => setShowInvoiceModal(false)}
|
||||
onConfirm={handleCreateInvoice}
|
||||
title="Crear factura"
|
||||
message={`¿Crear factura para el pedido ${order.name}? Total: ${formatCurrency(order.amountTotal, order.currencyCode)}`}
|
||||
variant="info"
|
||||
confirmText="Crear factura"
|
||||
isLoading={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SalesOrderDetailPage;
|
||||
847
src/pages/sales/SalesOrderEditPage.tsx
Normal file
847
src/pages/sales/SalesOrderEditPage.tsx
Normal file
@ -0,0 +1,847 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
ShoppingCart,
|
||||
User,
|
||||
Calendar,
|
||||
FileText,
|
||||
Package,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@components/atoms/Button';
|
||||
import { Spinner } from '@components/atoms/Spinner';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card';
|
||||
import { Alert } from '@components/molecules/Alert';
|
||||
import { Breadcrumbs } from '@components/organisms/Breadcrumbs';
|
||||
import { useToast } from '@components/organisms/Toast';
|
||||
import { ErrorEmptyState } from '@components/templates/EmptyState';
|
||||
import { useSalesOrder, useSalesOrders } from '@features/sales/hooks';
|
||||
import { usePartners } from '@features/partners/hooks';
|
||||
import type {
|
||||
UpdateSalesOrderDto,
|
||||
CreateSalesOrderLineDto,
|
||||
UpdateSalesOrderLineDto,
|
||||
InvoicePolicy,
|
||||
} from '@features/sales/types';
|
||||
import { formatCurrency } from '@shared/utils/formatters';
|
||||
|
||||
interface LineItemFormData {
|
||||
id?: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
uomId: string;
|
||||
uomName: string;
|
||||
priceUnit: number;
|
||||
discount: number;
|
||||
taxIds: string[];
|
||||
isNew?: boolean;
|
||||
isModified?: boolean;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
partnerId: string;
|
||||
clientOrderRef: string;
|
||||
orderDate: string;
|
||||
validityDate: string;
|
||||
commitmentDate: string;
|
||||
currencyId: string;
|
||||
pricelistId: string;
|
||||
paymentTermId: string;
|
||||
salesTeamId: string;
|
||||
invoicePolicy: InvoicePolicy;
|
||||
notes: string;
|
||||
termsConditions: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
partnerId?: string;
|
||||
orderDate?: string;
|
||||
currencyId?: string;
|
||||
lines?: string;
|
||||
}
|
||||
|
||||
export function SalesOrderEditPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Hooks
|
||||
const { order, isLoading: orderLoading, error: orderError, refresh, addLine, updateLine, removeLine } = useSalesOrder(id || null);
|
||||
const { updateOrder } = useSalesOrders({ autoFetch: false });
|
||||
const { partners, isLoading: partnersLoading } = usePartners({ isCustomer: true, limit: 100 });
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
partnerId: '',
|
||||
clientOrderRef: '',
|
||||
orderDate: '',
|
||||
validityDate: '',
|
||||
commitmentDate: '',
|
||||
currencyId: '',
|
||||
pricelistId: '',
|
||||
paymentTermId: '',
|
||||
salesTeamId: '',
|
||||
invoicePolicy: 'order',
|
||||
notes: '',
|
||||
termsConditions: '',
|
||||
});
|
||||
|
||||
const [lineItems, setLineItems] = useState<LineItemFormData[]>([]);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Populate form when order loads
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
setFormData({
|
||||
partnerId: order.partnerId,
|
||||
clientOrderRef: order.clientOrderRef || '',
|
||||
orderDate: order.orderDate?.split('T')[0] ?? '',
|
||||
validityDate: order.validityDate?.split('T')[0] || '',
|
||||
commitmentDate: order.commitmentDate?.split('T')[0] || '',
|
||||
currencyId: order.currencyId,
|
||||
pricelistId: order.pricelistId || '',
|
||||
paymentTermId: order.paymentTermId || '',
|
||||
salesTeamId: order.salesTeamId || '',
|
||||
invoicePolicy: order.invoicePolicy,
|
||||
notes: order.notes || '',
|
||||
termsConditions: order.termsConditions || '',
|
||||
});
|
||||
|
||||
// Load existing lines
|
||||
if (order.lines) {
|
||||
setLineItems(
|
||||
order.lines.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
productName: line.productName || '',
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
uomName: line.uomName || '',
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
taxIds: line.taxIds,
|
||||
isNew: false,
|
||||
isModified: false,
|
||||
isDeleted: false,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [order]);
|
||||
|
||||
// Check if order is editable (only draft orders can be edited)
|
||||
const isEditable = order?.status === 'draft';
|
||||
|
||||
// Redirect if not editable
|
||||
useEffect(() => {
|
||||
if (order && !isEditable) {
|
||||
showToast({
|
||||
type: 'warning',
|
||||
title: 'No editable',
|
||||
message: `El pedido ${order.name} no puede ser editado porque su estado es "${order.status}".`,
|
||||
});
|
||||
navigate(`/sales/orders/${id}`);
|
||||
}
|
||||
}, [order, isEditable, navigate, id, showToast]);
|
||||
|
||||
const handleFieldChange = (field: keyof FormData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
setHasChanges(true);
|
||||
if (errors[field as keyof FormErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLineChange = <K extends keyof LineItemFormData>(index: number, field: K, value: LineItemFormData[K]) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentItem = updated[index];
|
||||
if (!currentItem) return prev;
|
||||
updated[index] = {
|
||||
...currentItem,
|
||||
[field]: value,
|
||||
isModified: !currentItem.isNew,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const addNewLine = () => {
|
||||
setLineItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
productId: '',
|
||||
productName: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
uomId: '',
|
||||
uomName: '',
|
||||
priceUnit: 0,
|
||||
discount: 0,
|
||||
taxIds: [],
|
||||
isNew: true,
|
||||
isModified: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
]);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const markLineForDeletion = (index: number) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentItem = updated[index];
|
||||
if (!currentItem) return prev;
|
||||
if (currentItem.isNew) {
|
||||
// Remove new lines immediately
|
||||
return updated.filter((_, i) => i !== index);
|
||||
}
|
||||
// Mark existing lines for deletion
|
||||
updated[index] = { ...currentItem, isDeleted: true };
|
||||
return updated;
|
||||
});
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const restoreLine = (index: number) => {
|
||||
setLineItems((prev) => {
|
||||
const updated = [...prev];
|
||||
const currentItem = updated[index];
|
||||
if (!currentItem) return prev;
|
||||
updated[index] = { ...currentItem, isDeleted: false };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const calculateLineTotal = (line: LineItemFormData): number => {
|
||||
const subtotal = line.quantity * line.priceUnit;
|
||||
const discountAmount = subtotal * (line.discount / 100);
|
||||
return subtotal - discountAmount;
|
||||
};
|
||||
|
||||
const calculateTotals = () => {
|
||||
const activeLines = lineItems.filter((l) => !l.isDeleted);
|
||||
const subtotal = activeLines.reduce((sum, line) => sum + calculateLineTotal(line), 0);
|
||||
// For simplicity, estimate tax at 16% (IVA Mexico)
|
||||
const taxRate = 0.16;
|
||||
const tax = subtotal * taxRate;
|
||||
const total = subtotal + tax;
|
||||
return { subtotal, tax, total };
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
|
||||
if (!formData.partnerId) {
|
||||
newErrors.partnerId = 'El cliente es requerido';
|
||||
}
|
||||
if (!formData.orderDate) {
|
||||
newErrors.orderDate = 'La fecha de pedido es requerida';
|
||||
}
|
||||
if (!formData.currencyId) {
|
||||
newErrors.currencyId = 'La moneda es requerida';
|
||||
}
|
||||
|
||||
const activeLines = lineItems.filter((l) => !l.isDeleted);
|
||||
if (activeLines.length === 0) {
|
||||
newErrors.lines = 'Debe agregar al menos una linea de pedido';
|
||||
} else {
|
||||
const invalidLine = activeLines.find(
|
||||
(l) => !l.productId || l.quantity <= 0 || l.priceUnit < 0
|
||||
);
|
||||
if (invalidLine) {
|
||||
newErrors.lines = 'Todas las lineas deben tener producto, cantidad y precio validos';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm() || !id) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
try {
|
||||
// 1. Update order header
|
||||
const orderUpdate: UpdateSalesOrderDto = {
|
||||
partnerId: formData.partnerId,
|
||||
clientOrderRef: formData.clientOrderRef || null,
|
||||
orderDate: formData.orderDate,
|
||||
validityDate: formData.validityDate || null,
|
||||
commitmentDate: formData.commitmentDate || null,
|
||||
currencyId: formData.currencyId,
|
||||
pricelistId: formData.pricelistId || null,
|
||||
paymentTermId: formData.paymentTermId || null,
|
||||
salesTeamId: formData.salesTeamId || null,
|
||||
invoicePolicy: formData.invoicePolicy,
|
||||
notes: formData.notes || null,
|
||||
termsConditions: formData.termsConditions || null,
|
||||
};
|
||||
|
||||
await updateOrder(id, orderUpdate);
|
||||
|
||||
// 2. Process line items
|
||||
const linesToDelete = lineItems.filter((l) => l.isDeleted && l.id);
|
||||
const linesToUpdate = lineItems.filter((l) => l.isModified && l.id && !l.isDeleted);
|
||||
const linesToAdd = lineItems.filter((l) => l.isNew && !l.isDeleted);
|
||||
|
||||
// Delete lines
|
||||
for (const line of linesToDelete) {
|
||||
if (line.id) {
|
||||
await removeLine(line.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update existing lines
|
||||
for (const line of linesToUpdate) {
|
||||
if (line.id) {
|
||||
const updateData: UpdateSalesOrderLineDto = {
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
taxIds: line.taxIds,
|
||||
};
|
||||
await updateLine(line.id, updateData);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new lines
|
||||
for (const line of linesToAdd) {
|
||||
const createData: CreateSalesOrderLineDto = {
|
||||
productId: line.productId,
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
uomId: line.uomId,
|
||||
priceUnit: line.priceUnit,
|
||||
discount: line.discount,
|
||||
taxIds: line.taxIds,
|
||||
};
|
||||
await addLine(createData);
|
||||
}
|
||||
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Pedido actualizado',
|
||||
message: 'Los cambios han sido guardados exitosamente.',
|
||||
});
|
||||
|
||||
navigate(`/sales/orders/${id}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Error al actualizar el pedido';
|
||||
setSubmitError(message);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (orderLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (orderError || !order) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorEmptyState
|
||||
title="Pedido no encontrado"
|
||||
description="No se pudo cargar la informacion del pedido de venta."
|
||||
onRetry={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Ventas', href: '/sales' },
|
||||
{ label: 'Pedidos', href: '/sales/orders' },
|
||||
{ label: order.name, href: `/sales/orders/${id}` },
|
||||
{ label: 'Editar' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => navigate(`/sales/orders/${id}`)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Volver
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Editar Pedido de Venta {order.name}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Modifica la informacion del pedido
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Customer Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Informacion del Cliente
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cliente *
|
||||
</label>
|
||||
<select
|
||||
value={formData.partnerId}
|
||||
onChange={(e) => handleFieldChange('partnerId', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.partnerId
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
disabled={partnersLoading}
|
||||
>
|
||||
<option value="">Seleccionar cliente...</option>
|
||||
{partners.map((partner) => (
|
||||
<option key={partner.id} value={partner.id}>
|
||||
{partner.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.partnerId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.partnerId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Referencia del Cliente
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.clientOrderRef}
|
||||
onChange={(e) => handleFieldChange('clientOrderRef', e.target.value)}
|
||||
placeholder="Numero de orden del cliente"
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Order Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Detalles del Pedido
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha de Pedido *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.orderDate}
|
||||
onChange={(e) => handleFieldChange('orderDate', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.orderDate
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.orderDate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.orderDate}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha de Vigencia
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.validityDate}
|
||||
onChange={(e) => handleFieldChange('validityDate', e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Compromiso
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.commitmentDate}
|
||||
onChange={(e) => handleFieldChange('commitmentDate', e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Moneda *
|
||||
</label>
|
||||
<select
|
||||
value={formData.currencyId}
|
||||
onChange={(e) => handleFieldChange('currencyId', e.target.value)}
|
||||
className={`w-full rounded-md border px-3 py-2 focus:outline-none focus:ring-1 ${
|
||||
errors.currencyId
|
||||
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar moneda...</option>
|
||||
<option value="MXN">MXN - Peso Mexicano</option>
|
||||
<option value="USD">USD - Dolar Americano</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
</select>
|
||||
{errors.currencyId && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.currencyId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Politica de Facturacion
|
||||
</label>
|
||||
<select
|
||||
value={formData.invoicePolicy}
|
||||
onChange={(e) => handleFieldChange('invoicePolicy', e.target.value as InvoicePolicy)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="order">Facturar al pedido</option>
|
||||
<option value="delivery">Facturar a la entrega</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5" />
|
||||
Lineas del Pedido
|
||||
</CardTitle>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addNewLine}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Agregar Linea
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errors.lines && (
|
||||
<Alert variant="danger" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{errors.lines}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{lineItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-300" />
|
||||
<p className="mt-2">No hay lineas en el pedido</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={addNewLine}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Agregar primera linea
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="pb-2 font-medium">Producto</th>
|
||||
<th className="pb-2 font-medium">Descripcion</th>
|
||||
<th className="pb-2 font-medium text-right">Cantidad</th>
|
||||
<th className="pb-2 font-medium text-right">Precio</th>
|
||||
<th className="pb-2 font-medium text-right">Desc %</th>
|
||||
<th className="pb-2 font-medium text-right">Total</th>
|
||||
<th className="pb-2 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lineItems.map((line, index) => (
|
||||
<tr
|
||||
key={line.id || `new-${index}`}
|
||||
className={`border-b ${line.isDeleted ? 'bg-red-50 opacity-50' : ''}`}
|
||||
>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={line.productName || line.productId}
|
||||
onChange={(e) => handleLineChange(index, 'productId', e.target.value)}
|
||||
placeholder="ID Producto"
|
||||
disabled={line.isDeleted}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={line.description}
|
||||
onChange={(e) => handleLineChange(index, 'description', e.target.value)}
|
||||
placeholder="Descripcion"
|
||||
disabled={line.isDeleted}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.quantity}
|
||||
onChange={(e) => handleLineChange(index, 'quantity', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-20 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.priceUnit}
|
||||
onChange={(e) => handleLineChange(index, 'priceUnit', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-24 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="number"
|
||||
value={line.discount}
|
||||
onChange={(e) => handleLineChange(index, 'discount', parseFloat(e.target.value) || 0)}
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
disabled={line.isDeleted}
|
||||
className="w-16 rounded border border-gray-300 px-2 py-1 text-right text-sm focus:border-blue-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right font-medium">
|
||||
{formatCurrency(calculateLineTotal(line))}
|
||||
</td>
|
||||
<td className="py-2 pl-2">
|
||||
{line.isDeleted ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => restoreLine(index)}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Restaurar
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => markLineForDeletion(index)}
|
||||
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Notas y Terminos
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas Internas
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleFieldChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Notas internas sobre el pedido..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Terminos y Condiciones
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.termsConditions}
|
||||
onChange={(e) => handleFieldChange('termsConditions', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Terminos y condiciones para el cliente..."
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Summary */}
|
||||
<div className="space-y-6">
|
||||
{/* Submit Error */}
|
||||
{submitError && (
|
||||
<Alert
|
||||
variant="danger"
|
||||
title="Error al guardar"
|
||||
onClose={() => setSubmitError(null)}
|
||||
>
|
||||
{submitError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Order Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Resumen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Subtotal</span>
|
||||
<span>{formatCurrency(totals.subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">IVA (16%)</span>
|
||||
<span>{formatCurrency(totals.tax)}</span>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Total</span>
|
||||
<span className="text-lg">{formatCurrency(totals.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{lineItems.filter((l) => !l.isDeleted).length} linea(s) activa(s)
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !hasChanges}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => navigate(`/sales/orders/${id}`)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
{!hasChanges && (
|
||||
<p className="mt-3 text-center text-xs text-gray-400">
|
||||
No hay cambios para guardar
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Info */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700">
|
||||
Estado: Borrador
|
||||
</span>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Solo los pedidos en estado borrador pueden ser editados
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SalesOrderEditPage;
|
||||
@ -291,7 +291,7 @@ export function SalesOrdersPage() {
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
<Button>
|
||||
<Button onClick={() => navigate('/sales/orders/new')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nuevo pedido
|
||||
</Button>
|
||||
|
||||
@ -1,2 +1,8 @@
|
||||
export { SalesOrdersPage, default as SalesOrdersPageDefault } from './SalesOrdersPage';
|
||||
export { SalesOrderCreatePage, default as SalesOrderCreatePageDefault } from './SalesOrderCreatePage';
|
||||
export { SalesOrderDetailPage, default as SalesOrderDetailPageDefault } from './SalesOrderDetailPage';
|
||||
export { SalesOrderEditPage, default as SalesOrderEditPageDefault } from './SalesOrderEditPage';
|
||||
export { QuotationsPage, default as QuotationsPageDefault } from './QuotationsPage';
|
||||
export { QuotationDetailPage, default as QuotationDetailPageDefault } from './QuotationDetailPage';
|
||||
export { QuotationCreatePage, default as QuotationCreatePageDefault } from './QuotationCreatePage';
|
||||
export { QuotationEditPage, default as QuotationEditPageDefault } from './QuotationEditPage';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user