Feat ERP 2
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import CounterLayout from './layouts/CounterLayout';
|
||||
import FastEntryPage from './pages/FastEntryPage';
|
||||
import CashRegisterPage from './pages/CashRegisterPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdvancedAnalytics from './pages/AdvancedAnalytics';
|
||||
@@ -8,6 +7,7 @@ import LoginPage from './pages/LoginPage';
|
||||
import { ToastProvider } from './context/ToastContext';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import TreasuryPage from './pages/TreasuryPage';
|
||||
import UniversalPosPage from './pages/UniversalPosPage';
|
||||
|
||||
// Componente simple de protección
|
||||
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -25,7 +25,7 @@ function App() {
|
||||
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<AdminDashboard />} />
|
||||
<Route path="/nuevo-aviso" element={<FastEntryPage />} />
|
||||
<Route path="/pos" element={<UniversalPosPage />} />
|
||||
<Route path="/caja" element={<CashRegisterPage />} />
|
||||
<Route path="/analitica" element={<AdvancedAnalytics />} />
|
||||
<Route path="/historial" element={<HistoryPage />} />
|
||||
|
||||
299
frontend/counter-panel/src/components/POS/AdEditorModal.tsx
Normal file
299
frontend/counter-panel/src/components/POS/AdEditorModal.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Calendar, Type, AlignLeft, AlignCenter, AlignRight, AlignJustify, Bold, Square as FrameIcon } from 'lucide-react';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import api from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||
|
||||
interface AdEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (listingId: number, price: number, description: string) => void;
|
||||
clientId: number | null; // El aviso se vinculará a este cliente
|
||||
}
|
||||
|
||||
interface PricingResult {
|
||||
totalPrice: number;
|
||||
wordCount: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }: AdEditorModalProps) {
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [operations, setOperations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
|
||||
// Form State
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [operationId, setOperationId] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const debouncedText = useDebounce(text, 500);
|
||||
const [days, setDays] = useState(3);
|
||||
const [startDate, setStartDate] = useState(new Date(Date.now() + 86400000).toISOString().split('T')[0]); // Mañana default
|
||||
|
||||
// Styles
|
||||
const [styles, setStyles] = useState({
|
||||
isBold: false,
|
||||
isFrame: false,
|
||||
fontSize: 'normal',
|
||||
alignment: 'left'
|
||||
});
|
||||
|
||||
const [pricing, setPricing] = useState<PricingResult>({ totalPrice: 0, wordCount: 0, details: '' });
|
||||
|
||||
// Carga inicial de datos
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset state on open
|
||||
setText('');
|
||||
setDays(3);
|
||||
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
|
||||
setFlatCategories(processCategories(catRes.data));
|
||||
setOperations(opRes.data);
|
||||
} catch (e) {
|
||||
console.error("Error cargando configuración", e);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Calculadora de Precio en Tiempo Real
|
||||
useEffect(() => {
|
||||
if (!categoryId || !text) return;
|
||||
|
||||
const calculate = async () => {
|
||||
setCalculating(true);
|
||||
try {
|
||||
const res = await api.post('/pricing/calculate', {
|
||||
categoryId: parseInt(categoryId),
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: styles.isBold,
|
||||
isFrame: styles.isFrame,
|
||||
startDate: startDate
|
||||
});
|
||||
setPricing(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setCalculating(false);
|
||||
}
|
||||
};
|
||||
calculate();
|
||||
}, [debouncedText, categoryId, days, styles, startDate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
||||
if (!clientId) return alert("Error interno: Cliente no identificado");
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Creamos el aviso en estado 'Draft' (Borrador) o 'PendingPayment'
|
||||
const payload = {
|
||||
categoryId: parseInt(categoryId),
|
||||
operationId: parseInt(operationId),
|
||||
title: text.substring(0, 30) + '...',
|
||||
description: text,
|
||||
price: 0, // Precio del bien (opcional, no lo pedimos en este form simple)
|
||||
adFee: pricing.totalPrice, // Costo del aviso
|
||||
status: 'Draft', // Importante: Nace como borrador hasta que se paga la Orden
|
||||
origin: 'Mostrador',
|
||||
clientId: clientId,
|
||||
// Datos técnicos de impresión
|
||||
printText: text,
|
||||
printStartDate: startDate,
|
||||
printDaysCount: days,
|
||||
isBold: styles.isBold,
|
||||
isFrame: styles.isFrame,
|
||||
printFontSize: styles.fontSize,
|
||||
printAlignment: styles.alignment
|
||||
};
|
||||
|
||||
const res = await api.post('/listings', payload);
|
||||
|
||||
// Devolvemos el ID y el Precio al Carrito
|
||||
onConfirm(res.data.id, pricing.totalPrice, `Aviso: ${text.substring(0, 20)}... (${days} días)`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Error al guardar el borrador del aviso");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[200] flex items-center justify-center p-4">
|
||||
<div className="bg-white w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[90vh] overflow-hidden border border-slate-200">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-slate-50 px-8 py-5 border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Redacción de Aviso</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Configuración Técnica e Impresión</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl text-slate-400 transition-colors"><X /></button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8 overflow-y-auto flex-1 custom-scrollbar space-y-6">
|
||||
|
||||
{/* Clasificación */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Rubro</label>
|
||||
<select
|
||||
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm"
|
||||
value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">Seleccione Rubro...</option>
|
||||
{flatCategories.map(c => (
|
||||
<option key={c.id} value={c.id} disabled={!c.isSelectable}>
|
||||
{'\u00A0'.repeat(c.level * 2)} {c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Operación</label>
|
||||
<select
|
||||
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm"
|
||||
value={operationId} onChange={e => setOperationId(e.target.value)}
|
||||
>
|
||||
<option value="">Seleccione Operación...</option>
|
||||
{operations.map(op => <option key={op.id} value={op.id}>{op.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor de Texto */}
|
||||
<div className="flex gap-6 min-h-[200px]">
|
||||
<div className="flex-[2] flex flex-col gap-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Texto del Aviso</label>
|
||||
<textarea
|
||||
className={clsx(
|
||||
"w-full h-full p-4 border-2 border-slate-100 rounded-2xl resize-none outline-none focus:border-blue-500 transition-all font-mono text-sm leading-relaxed",
|
||||
styles.isBold && "font-bold",
|
||||
styles.fontSize === 'small' ? 'text-xs' : styles.fontSize === 'large' ? 'text-base' : 'text-sm',
|
||||
styles.alignment === 'center' ? 'text-center' : styles.alignment === 'right' ? 'text-right' : styles.alignment === 'justify' ? 'text-justify' : 'text-left'
|
||||
)}
|
||||
placeholder="Escriba el contenido aquí..."
|
||||
value={text} onChange={e => setText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Panel Lateral de Estilos */}
|
||||
<div className="flex-1 bg-slate-50 p-4 rounded-2xl border border-slate-100 space-y-4 h-fit">
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Alineación</label>
|
||||
<div className="flex bg-white p-1 rounded-lg border border-slate-200 justify-between">
|
||||
{['left', 'center', 'right', 'justify'].map(align => (
|
||||
<button
|
||||
key={align}
|
||||
onClick={() => setStyles({ ...styles, alignment: align })}
|
||||
className={clsx("p-2 rounded-md transition-all", styles.alignment === align ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
||||
>
|
||||
{align === 'left' && <AlignLeft size={16} />}
|
||||
{align === 'center' && <AlignCenter size={16} />}
|
||||
{align === 'right' && <AlignRight size={16} />}
|
||||
{align === 'justify' && <AlignJustify size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Estilos</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setStyles({ ...styles, isBold: !styles.isBold })}
|
||||
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isBold ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
||||
>
|
||||
<Bold size={12} /> Negrita
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStyles({ ...styles, isFrame: !styles.isFrame })}
|
||||
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isFrame ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
||||
>
|
||||
<FrameIcon size={12} /> Recuadro
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Tamaño Fuente</label>
|
||||
<div className="flex bg-white p-1 rounded-lg border border-slate-200 gap-1">
|
||||
{['small', 'normal', 'large'].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setStyles({ ...styles, fontSize: size })}
|
||||
className={clsx("flex-1 p-1.5 rounded-md flex justify-center items-end transition-all", styles.fontSize === size ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
||||
>
|
||||
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuración de Fecha */}
|
||||
<div className="grid grid-cols-3 gap-6 p-4 bg-blue-50/50 rounded-2xl border border-blue-100">
|
||||
<div className="col-span-1">
|
||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Inicio Publicación</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-300" size={16} />
|
||||
<input
|
||||
type="date"
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-blue-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
value={startDate} onChange={e => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Cantidad Días</label>
|
||||
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
|
||||
<button onClick={() => setDays(Math.max(1, days - 1))} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">-</button>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full text-center font-black text-slate-700 text-sm outline-none"
|
||||
value={days} onChange={e => setDays(parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
<button onClick={() => setDays(days + 1)} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex flex-col justify-center items-end">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
|
||||
<div className="text-2xl font-mono font-black text-slate-900">
|
||||
{calculating ? <span className="animate-pulse">...</span> : `$ ${pricing.totalPrice.toLocaleString()}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-white transition-all text-xs uppercase tracking-wider">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || calculating || !text}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Procesando...' : 'Confirmar y Agregar al Carrito'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/counter-panel/src/components/POS/ProductSearch.tsx
Normal file
100
frontend/counter-panel/src/components/POS/ProductSearch.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Search, Package, Layers, FileText } from 'lucide-react';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import type { Product } from '../../types/Product';
|
||||
|
||||
interface Props {
|
||||
products: Product[]; // Recibe el catálogo completo cargado en memoria (o podríamos hacerlo server-side search)
|
||||
onSelect: (product: Product) => void;
|
||||
}
|
||||
|
||||
export default function ProductSearch({ products, onSelect }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filtrado local (para catálogos de hasta ~5000 productos es instantáneo en JS)
|
||||
const filtered = products.filter(p =>
|
||||
p.isActive &&
|
||||
(p.name.toLowerCase().includes(debouncedQuery.toLowerCase()) ||
|
||||
p.sku?.toLowerCase().includes(debouncedQuery.toLowerCase()))
|
||||
).slice(0, 10); // Limitar resultados visuales
|
||||
|
||||
// Cerrar al hacer click afuera
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (p: Product) => {
|
||||
onSelect(p);
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const getIcon = (typeCode?: string) => {
|
||||
switch (typeCode) {
|
||||
case 'BUNDLE': return <Layers size={14} className="text-purple-500" />;
|
||||
case 'CLASSIFIED_AD': return <FileText size={14} className="text-blue-500" />;
|
||||
default: return <Package size={14} className="text-slate-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-4 py-3 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-500/10 font-bold text-sm transition-all"
|
||||
placeholder="Buscar producto por nombre o SKU (F3)..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && query.length > 1 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-slate-100 overflow-hidden z-50">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-4 text-center text-slate-400 text-xs font-bold uppercase tracking-widest">
|
||||
No se encontraron productos
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(product => (
|
||||
<button
|
||||
key={product.id}
|
||||
onClick={() => handleSelect(product)}
|
||||
className="w-full text-left p-3 hover:bg-blue-50 border-b border-slate-50 last:border-0 flex items-center justify-between group transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg group-hover:bg-white transition-colors">
|
||||
{getIcon(product.typeCode)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm text-slate-800 group-hover:text-blue-700">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider flex gap-2">
|
||||
<span>SKU: {product.sku || 'N/A'}</span>
|
||||
{product.typeCode === 'BUNDLE' && <span className="text-purple-500">• COMBO</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono font-black text-slate-900 group-hover:text-blue-700">
|
||||
$ {product.basePrice.toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CreditCard, Banknote, ArrowRightLeft, DollarSign, X } from 'lucide-react';
|
||||
import { CreditCard, Banknote, ArrowRightLeft, DollarSign, X, FileText, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { financeService } from '../services/financeService';
|
||||
import type { ClientProfile } from '../types/Finance';
|
||||
|
||||
// Interfaz de Pago
|
||||
// Interfaz de Pago actualizada
|
||||
export interface Payment {
|
||||
amount: number;
|
||||
paymentMethod: string;
|
||||
@@ -10,14 +12,13 @@ export interface Payment {
|
||||
surcharge: number;
|
||||
}
|
||||
|
||||
// Props del Modal de Pagos
|
||||
interface PaymentModalProps {
|
||||
totalAmount: number;
|
||||
onConfirm: (payments: Payment[]) => void;
|
||||
clientId: number | null;
|
||||
onConfirm: (payments: Payment[], isCreditSale: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Planes de Tarjetas de Crédito con sus recargos
|
||||
const CARD_PLANS = [
|
||||
{ value: 'Ahora12', label: 'Ahora 12', surcharge: 15 },
|
||||
{ value: 'Ahora18', label: 'Ahora 18', surcharge: 20 },
|
||||
@@ -25,32 +26,56 @@ const CARD_PLANS = [
|
||||
{ value: 'Credit6', label: '6 Cuotas', surcharge: 12 },
|
||||
];
|
||||
|
||||
export default function PaymentModal({ totalAmount, onConfirm, onCancel }: PaymentModalProps) {
|
||||
// Estado de pagos acumulados
|
||||
export default function PaymentModal({ totalAmount, clientId, onConfirm, onCancel }: PaymentModalProps) {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [currentMethod, setCurrentMethod] = useState('Cash');
|
||||
const [currentAmount, setCurrentAmount] = useState(totalAmount);
|
||||
const [currentPlan, setCurrentPlan] = useState('');
|
||||
|
||||
// Cálculos de totales
|
||||
// Estado Financiero
|
||||
const [clientProfile, setClientProfile] = useState<ClientProfile | null>(null);
|
||||
const [loadingProfile, setLoadingProfile] = useState(false);
|
||||
const [profileError, setProfileError] = useState('');
|
||||
|
||||
// Totales
|
||||
const totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const pendingAmount = totalAmount - totalPaid;
|
||||
|
||||
// Actualizar monto actual cuando cambia el pendiente
|
||||
// Cargar perfil al montar si hay cliente
|
||||
useEffect(() => {
|
||||
if (clientId && clientId !== 1005) { // Ignorar Consumidor Final (ID default)
|
||||
setLoadingProfile(true);
|
||||
financeService.getClientStatus(clientId)
|
||||
.then(setClientProfile)
|
||||
.catch(() => {
|
||||
// Si falla (ej: 404 no tiene perfil), asumimos que no tiene crédito habilitado
|
||||
setProfileError("Cliente sin perfil crediticio habilitado.");
|
||||
})
|
||||
.finally(() => setLoadingProfile(false));
|
||||
}
|
||||
}, [clientId]);
|
||||
|
||||
// Actualizar monto sugerido
|
||||
useEffect(() => {
|
||||
setCurrentAmount(Math.max(0, pendingAmount));
|
||||
}, [pendingAmount]);
|
||||
|
||||
// Agregar un nuevo pago a la lista
|
||||
const addPayment = () => {
|
||||
if (currentAmount <= 0 || currentAmount > pendingAmount) {
|
||||
if (currentAmount <= 0 || currentAmount > pendingAmount + 0.01) { // Pequeña tolerancia float
|
||||
alert('❌ Monto inválido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMethod === 'Credit' && !currentPlan) {
|
||||
alert('❌ Seleccione un plan de cuotas');
|
||||
return;
|
||||
// Validaciones específicas
|
||||
if (currentMethod === 'Credit' && !currentPlan) return alert('❌ Seleccione un plan de cuotas');
|
||||
|
||||
// VALIDACIÓN CUENTA CORRIENTE
|
||||
if (currentMethod === 'CurrentAccount') {
|
||||
if (!clientProfile) return alert("❌ Este cliente no tiene habilitada la Cuenta Corriente.");
|
||||
if (clientProfile.isCreditBlocked) return alert(`❌ Cuenta Bloqueada: ${clientProfile.blockReason}`);
|
||||
|
||||
const available = clientProfile.creditLimit - clientProfile.currentDebt;
|
||||
if (currentAmount > available) return alert(`❌ Límite excedido. Disponible: $${available.toLocaleString()}`);
|
||||
}
|
||||
|
||||
const planInfo = CARD_PLANS.find(p => p.value === currentPlan);
|
||||
@@ -64,87 +89,116 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
||||
};
|
||||
|
||||
setPayments([...payments, newPayment]);
|
||||
|
||||
// Resetear form
|
||||
setCurrentMethod('Cash');
|
||||
setCurrentPlan('');
|
||||
};
|
||||
|
||||
// Eliminar un pago de la lista
|
||||
const removePayment = (index: number) => {
|
||||
setPayments(payments.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Confirmar todos los pagos
|
||||
const handleConfirm = () => {
|
||||
if (pendingAmount > 0) {
|
||||
if (pendingAmount > 1) { // Tolerancia $1
|
||||
alert('❌ Aún falta completar el pago');
|
||||
return;
|
||||
}
|
||||
onConfirm(payments);
|
||||
|
||||
// Si hay AL MENOS UN pago de tipo 'CurrentAccount', la orden NO es pago directo
|
||||
const hasCreditComponent = payments.some(p => p.paymentMethod === 'CurrentAccount');
|
||||
|
||||
onConfirm(payments, hasCreditComponent);
|
||||
};
|
||||
|
||||
// Total incluyendo recargos
|
||||
const totalWithSurcharges = payments.reduce((sum, p) => sum + p.amount + p.surcharge, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Encabezado */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-6 rounded-t-3xl">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black">Procesar Pago</h2>
|
||||
<p className="text-sm text-blue-100 mt-1">Configure los medios de pago</p>
|
||||
</div>
|
||||
<button onClick={onCancel} className="hover:bg-white/20 p-2 rounded-full transition">
|
||||
<X size={24} />
|
||||
</button>
|
||||
// Helper para mostrar info de crédito
|
||||
const renderCreditInfo = () => {
|
||||
if (loadingProfile) return <div className="flex gap-2 text-xs text-slate-500 items-center"><Loader2 className="animate-spin" size={12} /> Verificando crédito...</div>;
|
||||
if (profileError) return <div className="text-xs text-slate-400 italic">Cta. Cte. no disponible ({profileError})</div>;
|
||||
if (!clientProfile) return <div className="text-xs text-slate-400 italic">Consumidor Final (Solo Contado)</div>;
|
||||
|
||||
const available = clientProfile.creditLimit - clientProfile.currentDebt;
|
||||
const isBlocked = clientProfile.isCreditBlocked;
|
||||
|
||||
return (
|
||||
<div className={clsx("text-xs p-2 rounded border mt-2", isBlocked ? "bg-rose-50 border-rose-200 text-rose-700" : "bg-blue-50 border-blue-200 text-blue-700")}>
|
||||
<div className="font-bold uppercase mb-1">{isBlocked ? "CRÉDITO BLOQUEADO" : "LÍNEA DE CRÉDITO"}</div>
|
||||
{!isBlocked && (
|
||||
<div className="flex justify-between font-mono">
|
||||
<span>Disponible:</span>
|
||||
<span className="font-black">${available.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{isBlocked && <div>{clientProfile.blockReason}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/80 backdrop-blur-sm flex items-center justify-center z-[300] p-4">
|
||||
<div className="bg-white rounded-[2rem] shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto flex flex-col border border-slate-200">
|
||||
|
||||
{/* Encabezado */}
|
||||
<div className="sticky top-0 bg-slate-50 px-8 py-6 border-b border-slate-200 flex justify-between items-center z-10">
|
||||
<div>
|
||||
<h2 className="text-xl font-black text-slate-900 uppercase tracking-tight">Procesar Pago</h2>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">Configure los medios de cobro</p>
|
||||
</div>
|
||||
<button onClick={onCancel} className="hover:bg-white p-2 rounded-xl transition-colors text-slate-400 hover:text-rose-500 border border-transparent hover:border-slate-200">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Resumen de Importes */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
|
||||
<div className="text-xs text-blue-600 font-bold uppercase mb-1">Total Original</div>
|
||||
<div className="text-2xl font-black text-blue-900">${totalAmount.toLocaleString()}</div>
|
||||
<div className="bg-white p-4 rounded-2xl border-2 border-slate-100 shadow-sm">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Total a Pagar</div>
|
||||
<div className="text-2xl font-mono font-black text-slate-800">${totalAmount.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl border border-green-200">
|
||||
<div className="text-xs text-green-600 font-bold uppercase mb-1">Pagado</div>
|
||||
<div className="text-2xl font-black text-green-900">${totalPaid.toLocaleString()}</div>
|
||||
<div className="bg-emerald-50 p-4 rounded-2xl border-2 border-emerald-100 shadow-sm">
|
||||
<div className="text-[9px] font-black text-emerald-600 uppercase tracking-widest mb-1">Cubierto</div>
|
||||
<div className="text-2xl font-mono font-black text-emerald-700">${totalPaid.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className={clsx("p-4 rounded-xl border", pendingAmount > 0 ? "bg-orange-50 border-orange-200" : "bg-gray-50 border-gray-200")}>
|
||||
<div className={clsx("text-xs font-bold uppercase mb-1", pendingAmount > 0 ? "text-orange-600" : "text-gray-500")}>Pendiente</div>
|
||||
<div className={clsx("text-2xl font-black", pendingAmount > 0 ? "text-orange-900" : "text-gray-400")}>${pendingAmount.toLocaleString()}</div>
|
||||
<div className={clsx("p-4 rounded-2xl border-2 shadow-sm transition-colors", pendingAmount > 1 ? "bg-rose-50 border-rose-100" : "bg-slate-50 border-slate-100")}>
|
||||
<div className={clsx("text-[9px] font-black uppercase tracking-widest mb-1", pendingAmount > 1 ? "text-rose-600" : "text-slate-400")}>Restante</div>
|
||||
<div className={clsx("text-2xl font-mono font-black", pendingAmount > 1 ? "text-rose-700" : "text-slate-300")}>${pendingAmount.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Pagos Agregados */}
|
||||
{payments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Pagos Agregados</h3>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Desglose de Pagos</h3>
|
||||
{payments.map((payment, index) => (
|
||||
<div key={index} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
{payment.paymentMethod === 'Cash' && <Banknote className="text-green-600" size={20} />}
|
||||
{payment.paymentMethod === 'Debit' && <CreditCard className="text-blue-600" size={20} />}
|
||||
{payment.paymentMethod === 'Credit' && <CreditCard className="text-purple-600" size={20} />}
|
||||
{payment.paymentMethod === 'Transfer' && <ArrowRightLeft className="text-indigo-600" size={20} />}
|
||||
<div key={index} className="flex items-center justify-between bg-white p-3 rounded-xl border border-slate-200 shadow-sm group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-slate-500">
|
||||
{payment.paymentMethod === 'Cash' && <Banknote size={18} />}
|
||||
{payment.paymentMethod === 'Debit' && <CreditCard size={18} />}
|
||||
{payment.paymentMethod === 'Credit' && <CreditCard size={18} className="text-purple-500" />}
|
||||
{payment.paymentMethod === 'Transfer' && <ArrowRightLeft size={18} />}
|
||||
{payment.paymentMethod === 'CurrentAccount' && <FileText size={18} className="text-blue-500" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-sm">
|
||||
<div className="font-bold text-xs text-slate-700 uppercase tracking-tight">
|
||||
{payment.paymentMethod === 'Cash' && 'Efectivo'}
|
||||
{payment.paymentMethod === 'Debit' && 'Débito'}
|
||||
{payment.paymentMethod === 'Credit' && payment.cardPlan}
|
||||
{payment.paymentMethod === 'Credit' && `Crédito (${payment.cardPlan})`}
|
||||
{payment.paymentMethod === 'Transfer' && 'Transferencia'}
|
||||
{payment.paymentMethod === 'CurrentAccount' && 'Cuenta Corriente'}
|
||||
</div>
|
||||
{payment.surcharge > 0 && (
|
||||
<div className="text-xs text-orange-600">+${payment.surcharge.toLocaleString()} recargo</div>
|
||||
<div className="text-[9px] font-bold text-orange-500">+${payment.surcharge.toLocaleString()} recargo</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-black text-lg">${payment.amount.toLocaleString()}</span>
|
||||
<button onClick={() => removePayment(index)} className="text-red-500 hover:bg-red-50 p-1 rounded">
|
||||
<X size={18} />
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-mono font-black text-slate-800">${payment.amount.toLocaleString()}</span>
|
||||
<button onClick={() => removePayment(index)} className="text-slate-300 hover:text-rose-500 transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -153,42 +207,49 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
||||
)}
|
||||
|
||||
{/* Formulario para Agregar Pago */}
|
||||
{pendingAmount > 0 && (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Agregar Pago</h3>
|
||||
{pendingAmount > 1 && (
|
||||
<div className="bg-slate-50 border-2 border-slate-100 rounded-[1.5rem] p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">Agregar Pago</h3>
|
||||
{renderCreditInfo()}
|
||||
</div>
|
||||
|
||||
{/* Selector de Método de Pago */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{/* Selector de Método */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[
|
||||
{ value: 'Cash', label: 'Efectivo', icon: Banknote, color: 'green' },
|
||||
{ value: 'Cash', label: 'Efectivo', icon: Banknote, color: 'emerald' },
|
||||
{ value: 'Debit', label: 'Débito', icon: CreditCard, color: 'blue' },
|
||||
{ value: 'Credit', label: 'Crédito', icon: CreditCard, color: 'purple' },
|
||||
{ value: 'Transfer', label: 'Transferencia', icon: ArrowRightLeft, color: 'indigo' }
|
||||
{ value: 'Transfer', label: 'Transf.', icon: ArrowRightLeft, color: 'indigo' },
|
||||
{ value: 'CurrentAccount', label: 'Cta. Cte.', icon: FileText, color: 'slate', disabled: !clientProfile || clientProfile.isCreditBlocked }
|
||||
].map((method) => (
|
||||
<button
|
||||
key={method.value}
|
||||
onClick={() => setCurrentMethod(method.value)}
|
||||
onClick={() => !method.disabled && setCurrentMethod(method.value)}
|
||||
disabled={method.disabled}
|
||||
className={clsx(
|
||||
"p-3 rounded-lg border-2 transition-all flex flex-col items-center gap-1",
|
||||
"p-3 rounded-xl border-2 transition-all flex flex-col items-center gap-2",
|
||||
currentMethod === method.value
|
||||
? `border-${method.color}-600 bg-${method.color}-50`
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
? `border-${method.color}-500 bg-white text-${method.color}-600 shadow-md`
|
||||
: method.disabled
|
||||
? "border-transparent bg-slate-100 text-slate-300 cursor-not-allowed grayscale"
|
||||
: "border-transparent bg-white text-slate-400 hover:bg-white hover:border-slate-300 hover:text-slate-600"
|
||||
)}
|
||||
>
|
||||
<method.icon size={20} className={currentMethod === method.value ? `text-${method.color}-600` : 'text-gray-400'} />
|
||||
<span className="text-xs font-bold">{method.label}</span>
|
||||
<method.icon size={20} />
|
||||
<span className="text-[9px] font-black uppercase">{method.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selector de Plan de Cuotas (solo para Crédito) */}
|
||||
{/* Selector de Plan (Crédito) */}
|
||||
{currentMethod === 'Credit' && (
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 uppercase mb-2">Plan de Cuotas</label>
|
||||
<label className="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Plan de Cuotas</label>
|
||||
<select
|
||||
value={currentPlan}
|
||||
onChange={(e) => setCurrentPlan(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg font-bold"
|
||||
className="w-full p-3 bg-white border-2 border-slate-200 rounded-xl font-bold text-xs text-slate-700 outline-none focus:border-purple-500 transition-all appearance-none"
|
||||
>
|
||||
<option value="">-- Seleccionar Plan --</option>
|
||||
{CARD_PLANS.map(plan => (
|
||||
@@ -202,62 +263,60 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
||||
|
||||
{/* Input de Monto */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-600 uppercase mb-2">Monto</label>
|
||||
<label className="block text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1.5 ml-1">Monto a imputar</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-3 text-gray-400" size={20} />
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="number"
|
||||
value={currentAmount}
|
||||
onChange={(e) => setCurrentAmount(parseFloat(e.target.value) || 0)}
|
||||
className="w-full pl-10 p-3 border border-gray-300 rounded-lg font-black text-xl"
|
||||
className="w-full pl-10 p-4 bg-white border-2 border-slate-200 rounded-xl font-mono font-black text-lg text-slate-800 outline-none focus:border-blue-500 transition-all"
|
||||
max={pendingAmount}
|
||||
/>
|
||||
<button
|
||||
onClick={addPayment}
|
||||
className="absolute right-2 top-2 bottom-2 bg-slate-900 text-white px-6 rounded-lg font-black text-[10px] uppercase tracking-widest hover:bg-black transition-all"
|
||||
>
|
||||
Imputar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addPayment}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-3 rounded-lg font-black uppercase transition"
|
||||
>
|
||||
+ Agregar Pago
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resumen Final con Recargos */}
|
||||
{totalWithSurcharges !== totalAmount && (
|
||||
<div className="bg-orange-50 border border-orange-200 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-bold text-orange-700">Total con Recargos</span>
|
||||
<span className="text-2xl font-black text-orange-900">${totalWithSurcharges.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="bg-orange-50 border border-orange-100 p-4 rounded-xl flex justify-between items-center text-orange-800">
|
||||
<span className="text-xs font-black uppercase tracking-widest flex items-center gap-2">
|
||||
<AlertCircle size={16} /> Total Final (Con Recargos)
|
||||
</span>
|
||||
<span className="text-xl font-mono font-black">${totalWithSurcharges.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botones de Acción */}
|
||||
<div className="sticky bottom-0 bg-gray-50 p-6 rounded-b-3xl border-t border-gray-200 flex gap-3">
|
||||
<div className="sticky bottom-0 bg-slate-50 p-6 border-t border-slate-200 flex gap-4 z-10">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-white border-2 border-gray-300 text-gray-700 py-4 rounded-xl font-black uppercase hover:bg-gray-100 transition"
|
||||
className="flex-1 bg-white border-2 border-slate-200 text-slate-500 py-4 rounded-xl font-black text-xs uppercase tracking-widest hover:bg-slate-100 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={pendingAmount > 0}
|
||||
disabled={pendingAmount > 1}
|
||||
className={clsx(
|
||||
"flex-1 py-4 rounded-xl font-black uppercase transition",
|
||||
pendingAmount > 0
|
||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
: "bg-green-600 hover:bg-green-700 text-white"
|
||||
"flex-[2] py-4 rounded-xl font-black text-xs uppercase tracking-widest transition-all shadow-lg",
|
||||
pendingAmount > 1
|
||||
? "bg-slate-200 text-slate-400 cursor-not-allowed shadow-none"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700 shadow-blue-200"
|
||||
)}
|
||||
>
|
||||
{pendingAmount > 0 ? 'Completar Pagos' : 'Confirmar Cobro'}
|
||||
{pendingAmount > 1 ? `Faltan $${pendingAmount.toLocaleString()}` : 'Confirmar Operación'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function CounterLayout() {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const map: Record<string, string> = {
|
||||
'F1': '/dashboard',
|
||||
'F2': '/nuevo-aviso',
|
||||
'F2': '/pos',
|
||||
'F4': '/caja',
|
||||
'F6': '/analitica',
|
||||
'F8': '/historial',
|
||||
@@ -70,7 +70,7 @@ export default function CounterLayout() {
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', label: 'Panel Principal', icon: LayoutDashboard, shortcut: 'F1' },
|
||||
{ path: '/nuevo-aviso', label: 'Operar Caja', icon: PlusCircle, shortcut: 'F2' },
|
||||
{ path: '/pos', label: 'Operar Caja', icon: PlusCircle, shortcut: 'F2' },
|
||||
{ path: '/caja', label: 'Caja Diaria', icon: Banknote, shortcut: 'F4' },
|
||||
{ path: '/historial', label: 'Consultas', icon: ClipboardList, shortcut: 'F8' },
|
||||
{ path: '/analitica', label: 'Analítica', icon: TrendingUp, shortcut: 'F6' },
|
||||
|
||||
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCartStore } from '../store/cartStore';
|
||||
import { productService } from '../services/productService';
|
||||
import type { Product } from '../types/Product';
|
||||
import ProductSearch from '../components/POS/ProductSearch';
|
||||
import { Trash2, ShoppingCart, CreditCard, User, Box, Layers } from 'lucide-react';
|
||||
import { useToast } from '../context/use-toast';
|
||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||
import { orderService } from '../services/orderService';
|
||||
import type { CreateOrderRequest } from '../types/Order';
|
||||
import AdEditorModal from '../components/POS/AdEditorModal';
|
||||
// Importamos el componente de búsqueda de clientes para el modal (Asumiremos que existe o usamos un simple prompt por ahora para no extender demasiado, idealmente ClientSearchModal)
|
||||
// import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||
|
||||
export default function UniversalPosPage() {
|
||||
const { showToast } = useToast();
|
||||
const { items, addItem, removeItem, clearCart, getTotal, clientId, clientName, setClient, sellerId, setSeller } = useCartStore();
|
||||
|
||||
const [catalog, setCatalog] = useState<Product[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showPayment, setShowPayment] = useState(false);
|
||||
|
||||
// Estados de Modales
|
||||
const [showAdEditor, setShowAdEditor] = useState(false);
|
||||
const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null);
|
||||
|
||||
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
|
||||
const [addingProduct, setAddingProduct] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
productService.getAll().then(setCatalog).catch(console.error);
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user.id) setSeller(user.id);
|
||||
} catch { /* ... */ }
|
||||
}
|
||||
}, [setSeller]);
|
||||
|
||||
// Manejador de Teclado Global del POS
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// F10: Cobrar
|
||||
if (e.key === 'F10') {
|
||||
e.preventDefault();
|
||||
handleCheckout();
|
||||
}
|
||||
// F7: Cambiar Cliente (Antes F9)
|
||||
if (e.key === 'F7') {
|
||||
e.preventDefault();
|
||||
handleChangeClient();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco
|
||||
|
||||
const handleChangeClient = () => {
|
||||
// Aquí abriríamos el ClientSearchModal.
|
||||
// Para no bloquear, simulamos un cambio rápido o un prompt simple si no hay modal aún.
|
||||
// En producción: setShowClientModal(true);
|
||||
const id = prompt("Ingrese ID de Cliente (Simulación F7):", "1003");
|
||||
if (id) {
|
||||
// Buscar nombre real en API...
|
||||
setClient(parseInt(id), "Cliente #" + id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProductSelect = async (product: Product) => {
|
||||
setAddingProduct(true);
|
||||
try {
|
||||
// 1. AVISOS CLASIFICADOS
|
||||
if (product.typeCode === 'CLASSIFIED_AD') {
|
||||
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
||||
setSelectedAdProduct(product);
|
||||
setShowAdEditor(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización
|
||||
if (product.typeCode === 'BUNDLE') {
|
||||
// Traemos los componentes para mostrarlos en el ticket
|
||||
const components = await productService.getBundleComponents(product.id);
|
||||
const subItemsNames = components.map(c =>
|
||||
`${c.quantity}x ${c.childProduct?.name || 'Item'}`
|
||||
);
|
||||
|
||||
addItem(product, 1, { subItems: subItemsNames });
|
||||
showToast(`Combo agregado con ${components.length} ítems`, 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. PRODUCTO ESTÁNDAR
|
||||
addItem(product, 1);
|
||||
showToast(`${product.name} agregado`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showToast("Error al agregar producto", "error");
|
||||
} finally {
|
||||
setAddingProduct(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdConfirmed = (listingId: number, price: number, description: string) => {
|
||||
if (selectedAdProduct) {
|
||||
addItem(
|
||||
{ ...selectedAdProduct, basePrice: price },
|
||||
1,
|
||||
{
|
||||
relatedEntity: { id: listingId, type: 'Listing', extraInfo: description }
|
||||
}
|
||||
);
|
||||
showToast('Aviso agregado al carrito', 'success');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
||||
if (!clientId) setClient(1005, "Consumidor Final");
|
||||
setShowPayment(true);
|
||||
};
|
||||
|
||||
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const isDirectPayment = !isCreditSale;
|
||||
const payload: CreateOrderRequest = {
|
||||
clientId: clientId || 1005,
|
||||
sellerId: sellerId || 2,
|
||||
isDirectPayment: isDirectPayment,
|
||||
notes: "Venta de Mostrador (Universal POS)",
|
||||
items: items.map(i => ({
|
||||
productId: i.productId,
|
||||
quantity: i.quantity,
|
||||
relatedEntityId: i.relatedEntityId,
|
||||
relatedEntityType: i.relatedEntityType
|
||||
}))
|
||||
};
|
||||
|
||||
const result = await orderService.createOrder(payload);
|
||||
showToast(`Orden ${result.orderNumber} generada con éxito`, 'success');
|
||||
clearCart();
|
||||
setShowPayment(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const msg = error.response?.data?.message || error.message || "Error al procesar la venta";
|
||||
showToast(msg, "error");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`h-full flex gap-6 p-6 ${addingProduct ? 'cursor-wait opacity-80' : ''}`}>
|
||||
{/* SECCIÓN IZQUIERDA */}
|
||||
<div className="flex-[2] flex flex-col gap-6 min-w-0">
|
||||
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-200">
|
||||
<h2 className="text-xl font-black text-slate-800 uppercase tracking-tight mb-4 flex items-center gap-2">
|
||||
<Box className="text-blue-600" /> Nueva Venta
|
||||
</h2>
|
||||
<ProductSearch products={catalog} onSelect={handleProductSelect} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-6 overflow-y-auto custom-scrollbar">
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Accesos Rápidos</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{catalog.filter(p => p.typeCode === 'PHYSICAL' || p.typeCode === 'BUNDLE').slice(0, 9).map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handleProductSelect(p)}
|
||||
className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 hover:border-blue-400 hover:shadow-md transition-all text-left group flex flex-col justify-between h-24 relative overflow-hidden"
|
||||
>
|
||||
{p.typeCode === 'BUNDLE' && (
|
||||
<div className="absolute top-0 right-0 bg-purple-100 text-purple-600 p-1 rounded-bl-lg">
|
||||
<Layers size={12} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-bold text-slate-700 group-hover:text-blue-700 line-clamp-2 leading-tight pr-4">
|
||||
{p.name}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black text-slate-400 mt-1 uppercase">{p.typeCode === 'BUNDLE' ? 'Combo' : 'Producto'}</div>
|
||||
<div className="text-sm font-black text-slate-900">$ {p.basePrice.toLocaleString()}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECCIÓN DERECHA: CARRITO */}
|
||||
<div className="flex-1 bg-white rounded-[2rem] shadow-xl border border-slate-200 flex flex-col overflow-hidden min-w-[340px]">
|
||||
{/* Header Carrito */}
|
||||
<div className="p-6 bg-slate-900 text-white border-b border-slate-800 shrink-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-blue-400">Orden Actual</span>
|
||||
<ShoppingCart size={18} className="text-slate-400" />
|
||||
</div>
|
||||
<div className="text-3xl font-mono font-black tracking-tight truncate">
|
||||
$ {getTotal().toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Items */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||
{items.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-2">
|
||||
<ShoppingCart size={48} className="opacity-20" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Carrito Vacío</span>
|
||||
</div>
|
||||
) : (
|
||||
items.map(item => (
|
||||
<div key={item.tempId} className="flex flex-col bg-slate-50 rounded-xl border border-slate-100 group hover:border-blue-200 transition-colors overflow-hidden">
|
||||
{/* Cabecera del Item */}
|
||||
<div className="flex justify-between items-center p-3">
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="text-xs font-bold text-slate-800 truncate" title={item.productName}>
|
||||
{item.productName}
|
||||
</div>
|
||||
<div className="text-[10px] font-mono text-slate-500">
|
||||
{item.quantity} x ${item.unitPrice.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="font-mono font-black text-sm text-slate-900">${item.subTotal.toLocaleString()}</span>
|
||||
<button onClick={() => removeItem(item.tempId)} className="text-rose-300 hover:text-rose-500 transition-colors p-1 hover:bg-rose-50 rounded">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VISUALIZACIÓN DE COMPONENTES DEL COMBO */}
|
||||
{item.subItems && item.subItems.length > 0 && (
|
||||
<div className="bg-purple-50/50 px-3 py-2 border-t border-slate-100">
|
||||
<div className="text-[9px] font-black text-purple-400 uppercase tracking-widest mb-1 flex items-center gap-1">
|
||||
<Layers size={10} /> Incluye:
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{item.subItems.map((sub, idx) => (
|
||||
<li key={idx} className="text-[10px] text-slate-600 pl-3 relative flex items-center gap-1">
|
||||
<div className="w-1 h-1 bg-purple-300 rounded-full"></div>
|
||||
{sub}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cliente y Acciones */}
|
||||
<div className="p-6 border-t border-slate-100 bg-slate-50 space-y-4 shrink-0">
|
||||
<div
|
||||
onClick={handleChangeClient}
|
||||
className="flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 cursor-pointer hover:border-blue-300 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg group-hover:bg-blue-100 transition-colors"><User size={16} /></div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Cliente</div>
|
||||
<div className="text-xs font-bold text-slate-800 truncate">{clientName || "Consumidor Final"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-blue-600 uppercase whitespace-nowrap">F7 Cambiar</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
disabled={items.length === 0 || isProcessing}
|
||||
className="w-full py-4 bg-emerald-600 text-white rounded-xl font-black uppercase text-xs tracking-widest shadow-lg shadow-emerald-200 hover:bg-emerald-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
|
||||
>
|
||||
<CreditCard size={16} /> {isProcessing ? 'Procesando...' : 'Cobrar Orden (F10)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- MODALES --- */}
|
||||
|
||||
{showPayment && (
|
||||
<PaymentModal
|
||||
totalAmount={getTotal()}
|
||||
clientId={clientId}
|
||||
onConfirm={finalizeOrder}
|
||||
onCancel={() => setShowPayment(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAdEditor && (
|
||||
<AdEditorModal
|
||||
isOpen={showAdEditor}
|
||||
onClose={() => setShowAdEditor(false)}
|
||||
onConfirm={handleAdConfirmed}
|
||||
clientId={clientId || 1005}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
frontend/counter-panel/src/services/companyService.ts
Normal file
12
frontend/counter-panel/src/services/companyService.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from './api';
|
||||
import type { Company } from '../types/Company';
|
||||
|
||||
export const companyService = {
|
||||
/**
|
||||
* Obtiene el listado de empresas activas para selectores.
|
||||
*/
|
||||
getAll: async (): Promise<Company[]> => {
|
||||
const response = await api.get<Company[]>('/products/companies');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
12
frontend/counter-panel/src/services/financeService.ts
Normal file
12
frontend/counter-panel/src/services/financeService.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from './api';
|
||||
import type { ClientProfile } from '../types/Finance';
|
||||
|
||||
export const financeService = {
|
||||
/**
|
||||
* Obtiene el perfil financiero y la deuda actual de un cliente.
|
||||
*/
|
||||
getClientStatus: async (clientId: number): Promise<ClientProfile> => {
|
||||
const response = await api.get<ClientProfile>(`/finance/client/${clientId}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
32
frontend/counter-panel/src/services/orderService.ts
Normal file
32
frontend/counter-panel/src/services/orderService.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import api from './api';
|
||||
import type { CreateOrderRequest, OrderResult } from '../types/Order';
|
||||
|
||||
export const orderService = {
|
||||
/**
|
||||
* Crea una orden de venta completa (Contado o Cta Cte)
|
||||
*/
|
||||
createOrder: async (request: CreateOrderRequest): Promise<OrderResult> => {
|
||||
// Validaciones de seguridad antes de enviar
|
||||
if (request.items.length === 0) throw new Error("La orden no puede estar vacía.");
|
||||
if (request.clientId <= 0) throw new Error("Debe seleccionar un cliente.");
|
||||
|
||||
const response = await api.post<OrderResult>('/orders', request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene el historial de órdenes de un cliente específico
|
||||
*/
|
||||
getByClient: async (clientId: number) => {
|
||||
const response = await api.get(`/orders/client/${clientId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene el detalle completo de una orden por ID
|
||||
*/
|
||||
getById: async (id: number) => {
|
||||
const response = await api.get(`/orders/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
49
frontend/counter-panel/src/services/productService.ts
Normal file
49
frontend/counter-panel/src/services/productService.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import api from './api';
|
||||
import type { Product, ProductBundleComponent } from '../types/Product';
|
||||
|
||||
export const productService = {
|
||||
getAll: async (): Promise<Product[]> => {
|
||||
const response = await api.get<Product[]>('/products');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<Product> => {
|
||||
const response = await api.get<Product>(`/products/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (product: Partial<Product>): Promise<Product> => {
|
||||
const response = await api.post<Product>('/products', product);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, product: Partial<Product>): Promise<void> => {
|
||||
await api.put(`/products/${id}`, product);
|
||||
},
|
||||
|
||||
// --- GESTIÓN DE COMBOS (BUNDLES) ---
|
||||
|
||||
/**
|
||||
* Agrega un producto hijo a un combo padre.
|
||||
*/
|
||||
addComponentToBundle: async (bundleId: number, component: { childProductId: number; quantity: number; fixedAllocationAmount?: number }) => {
|
||||
await api.post(`/products/${bundleId}/components`, component);
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina un componente de un combo.
|
||||
*/
|
||||
removeComponentFromBundle: async (bundleId: number, childProductId: number) => {
|
||||
// Nota: El backend espera el ID del producto hijo, no el ID de la relación,
|
||||
// según nuestra implementación de 'RemoveComponentFromBundleAsync' en el repo.
|
||||
await api.delete(`/products/${bundleId}/components/${childProductId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene la lista de componentes que forman un combo.
|
||||
*/
|
||||
getBundleComponents: async (bundleId: number): Promise<ProductBundleComponent[]> => {
|
||||
const response = await api.get<ProductBundleComponent[]>(`/products/${bundleId}/components`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
84
frontend/counter-panel/src/store/cartStore.ts
Normal file
84
frontend/counter-panel/src/store/cartStore.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { create } from 'zustand';
|
||||
import type { OrderItemDto } from '../types/Order';
|
||||
import type { Product } from '../types/Product';
|
||||
|
||||
interface CartItem extends OrderItemDto {
|
||||
tempId: string;
|
||||
productName: string;
|
||||
unitPrice: number;
|
||||
subTotal: number;
|
||||
// Lista de nombres de componentes para mostrar en el ticket/pantalla
|
||||
subItems?: string[];
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
items: CartItem[];
|
||||
clientId: number | null;
|
||||
clientName: string | null;
|
||||
sellerId: number | null;
|
||||
|
||||
addItem: (
|
||||
product: Product,
|
||||
quantity: number,
|
||||
options?: {
|
||||
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
||||
subItems?: string[]
|
||||
}
|
||||
) => void;
|
||||
|
||||
removeItem: (tempId: string) => void;
|
||||
setClient: (id: number, name: string) => void;
|
||||
setSeller: (id: number) => void;
|
||||
clearCart: () => void;
|
||||
getTotal: () => number;
|
||||
}
|
||||
|
||||
export const useCartStore = create<CartState>((set, get) => ({
|
||||
items: [],
|
||||
clientId: null,
|
||||
clientName: null,
|
||||
sellerId: null,
|
||||
|
||||
addItem: (product, quantity, options) => {
|
||||
const currentItems = get().items;
|
||||
const { relatedEntity, subItems } = options || {};
|
||||
|
||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo), no agrupamos
|
||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0);
|
||||
|
||||
const existingIndex = !isComplexItem
|
||||
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
||||
: -1;
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const updatedItems = [...currentItems];
|
||||
updatedItems[existingIndex].quantity += quantity;
|
||||
updatedItems[existingIndex].subTotal = updatedItems[existingIndex].quantity * updatedItems[existingIndex].unitPrice;
|
||||
set({ items: updatedItems });
|
||||
} else {
|
||||
const newItem: CartItem = {
|
||||
tempId: Math.random().toString(36).substring(7),
|
||||
productId: product.id,
|
||||
quantity: quantity,
|
||||
productName: relatedEntity?.extraInfo
|
||||
? `${product.name} (${relatedEntity.extraInfo})`
|
||||
: product.name,
|
||||
unitPrice: product.basePrice,
|
||||
subTotal: product.basePrice * quantity,
|
||||
relatedEntityId: relatedEntity?.id,
|
||||
relatedEntityType: relatedEntity?.type,
|
||||
subItems: subItems // Guardamos la lista visual
|
||||
};
|
||||
set({ items: [...currentItems, newItem] });
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: (tempId) => {
|
||||
set({ items: get().items.filter(i => i.tempId !== tempId) });
|
||||
},
|
||||
|
||||
setClient: (id, name) => set({ clientId: id, clientName: name }),
|
||||
setSeller: (id) => set({ sellerId: id }),
|
||||
clearCart: () => set({ items: [], clientId: null, clientName: null }),
|
||||
getTotal: () => get().items.reduce((sum, item) => sum + item.subTotal, 0)
|
||||
}));
|
||||
9
frontend/counter-panel/src/types/Company.ts
Normal file
9
frontend/counter-panel/src/types/Company.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
taxId: string; // CUIT
|
||||
legalAddress?: string;
|
||||
logoUrl?: string;
|
||||
externalSystemId?: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
11
frontend/counter-panel/src/types/Finance.ts
Normal file
11
frontend/counter-panel/src/types/Finance.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface ClientProfile {
|
||||
userId: number;
|
||||
creditLimit: number;
|
||||
paymentTermsDays: number;
|
||||
isCreditBlocked: boolean;
|
||||
blockReason?: string;
|
||||
lastCreditCheckAt: string;
|
||||
|
||||
// Propiedad calculada en backend
|
||||
currentDebt: number;
|
||||
}
|
||||
28
frontend/counter-panel/src/types/Order.ts
Normal file
28
frontend/counter-panel/src/types/Order.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface OrderItemDto {
|
||||
productId: number;
|
||||
quantity: number;
|
||||
|
||||
// Para trazabilidad en UI (no se envía al backend, pero sirve para mostrar)
|
||||
productName?: string;
|
||||
unitPrice?: number;
|
||||
|
||||
// Vinculación Polimórfica (para Avisos/Servicios complejos)
|
||||
relatedEntityId?: number;
|
||||
relatedEntityType?: string; // 'Listing', etc.
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
clientId: number;
|
||||
sellerId: number;
|
||||
dueDate?: string; // ISO Date para Cta Cte
|
||||
notes?: string;
|
||||
isDirectPayment: boolean; // true = Contado, false = Cta Cte
|
||||
items: OrderItemDto[];
|
||||
}
|
||||
|
||||
export interface OrderResult {
|
||||
orderId: number;
|
||||
orderNumber: string;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
}
|
||||
28
frontend/counter-panel/src/types/Product.ts
Normal file
28
frontend/counter-panel/src/types/Product.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Product {
|
||||
id: number;
|
||||
companyId: number;
|
||||
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
|
||||
name: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
externalId?: string;
|
||||
basePrice: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
|
||||
// Campos extendidos para UI (Joins)
|
||||
companyName?: string;
|
||||
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||
}
|
||||
|
||||
export interface ProductBundleComponent {
|
||||
id: number;
|
||||
parentProductId: number;
|
||||
childProductId: number;
|
||||
quantity: number;
|
||||
fixedAllocationAmount?: number;
|
||||
|
||||
// Datos del hijo para visualización
|
||||
childProduct?: Product;
|
||||
}
|
||||
7
frontend/counter-panel/src/types/Seller.ts
Normal file
7
frontend/counter-panel/src/types/Seller.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SellerProfile {
|
||||
userId: number;
|
||||
sellerCode?: string;
|
||||
baseCommissionPercentage: number;
|
||||
isActive: boolean;
|
||||
username?: string; // Para mostrar el nombre
|
||||
}
|
||||
Reference in New Issue
Block a user