Feat ERP 2

This commit is contained in:
2026-01-07 17:52:10 -03:00
parent fdb221d0fa
commit 29aa8e30e7
54 changed files with 3035 additions and 275 deletions

View File

@@ -11,6 +11,7 @@
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.24.10",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -3078,6 +3079,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.24.10.tgz",
"integrity": "sha512-8yoyMkCn2RmV9UB9mfmMuzKyenQe909hRQRl0yGBhbZJjZZ9bSU87NIGAruqCXCuTNCA0qHw2LWLrcXLL9GF6A==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.24.10",
"motion-utils": "^12.24.10",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3788,6 +3816,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.24.10.tgz",
"integrity": "sha512-H3HStYaJ6wANoZVNT0ZmYZHGvrpvi9pKJRzsgNEHkdITR4Qd9FFu2e9sH4e2Phr4tKCmyyloex6SOSmv0Tlq+g==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.24.10"
}
},
"node_modules/motion-utils": {
"version": "12.24.10",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.24.10.tgz",
"integrity": "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4337,6 +4380,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -13,6 +13,7 @@
"@tailwindcss/postcss": "^4.1.18",
"axios": "^1.13.2",
"clsx": "^2.1.1",
"framer-motion": "^12.24.10",
"lucide-react": "^0.561.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",

View File

@@ -13,6 +13,11 @@ import ListingExplorer from './pages/Listings/ListingExplorer';
import AuditTimeline from './pages/Audit/AuditTimeline';
import ClientManager from './pages/Clients/ClientManager';
import CouponsPage from './pages/Coupons/CouponsPage';
import ProductManager from './pages/Products/ProductManager';
import CompanyManager from './pages/Companies/CompanyManager';
import CreditManager from './pages/Finance/CreditManager';
import CalendarManager from './pages/Companies/CalendarManager';
import SettlementReport from './pages/Reports/SettlementReport';
function App() {
return (
@@ -28,11 +33,16 @@ function App() {
<Route path="/clients" element={<ClientManager />} />
<Route path="/users" element={<UserManager />} />
<Route path="/diagram" element={<DiagramPage />} />
<Route path="/products" element={<ProductManager />} />
<Route path="/pricing" element={<PricingManager />} />
<Route path="/promotions" element={<PromotionsManager />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/reports/categories" element={<SalesByCategory />} />
<Route path="/audit" element={<AuditTimeline />} />
<Route path="/companies" element={<CompanyManager />} />
<Route path="/finance/credit" element={<CreditManager />} />
<Route path="/companies/calendar" element={<CalendarManager />} />
<Route path="/reports/settlement" element={<SettlementReport />} />
</Route>
</Routes>
</BrowserRouter>

View File

@@ -0,0 +1,116 @@
import { useState, useEffect } from 'react';
import type { Company } from '../../types/Company';
import { companyService } from '../../services/companyService';
import { X, Save, Building2, FileText, MapPin, Link } from 'lucide-react';
import { motion } from 'framer-motion';
interface Props {
company: Company | null;
onClose: (refresh?: boolean) => void;
}
export default function CompanyModal({ company, onClose }: Props) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<Partial<Company>>({
name: '',
taxId: '',
legalAddress: '',
logoUrl: '',
externalSystemId: '',
isActive: true
});
useEffect(() => {
if (company) setFormData(company);
}, [company]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
if (company) {
await companyService.update(company.id, formData);
} else {
await companyService.create(formData);
}
onClose(true);
} catch (error) {
alert("Error al guardar la empresa");
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white w-full max-w-lg rounded-[2rem] shadow-2xl overflow-hidden border border-slate-100 flex flex-col"
>
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Building2 size={20} className="text-blue-600" />
{company ? 'Editar Empresa' : 'Nueva Empresa'}
</h3>
<button onClick={() => onClose()} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
</div>
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<Building2 size={12} /> Razón Social / Nombre
</label>
<input required type="text" 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={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<FileText size={12} /> CUIT / Tax ID
</label>
<input required type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-mono font-bold text-sm"
value={formData.taxId} onChange={e => setFormData({ ...formData, taxId: e.target.value })} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<Link size={12} /> ID Externo (ERP)
</label>
<input type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-mono font-bold text-sm"
placeholder="Fac-001"
value={formData.externalSystemId || ''} onChange={e => setFormData({ ...formData, externalSystemId: e.target.value })} />
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
<MapPin size={12} /> Dirección Legal
</label>
<input type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-medium text-sm"
value={formData.legalAddress || ''} onChange={e => setFormData({ ...formData, legalAddress: e.target.value })} />
</div>
<div className="flex items-center gap-3 pt-2">
<input
type="checkbox"
id="isActive"
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500 border-gray-300"
checked={formData.isActive}
onChange={e => setFormData({ ...formData, isActive: e.target.checked })}
/>
<label htmlFor="isActive" className="text-sm font-bold text-slate-700">Empresa Activa y Operativa</label>
</div>
<div className="pt-4">
<button
type="submit" disabled={loading}
className="w-full bg-blue-600 text-white py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center justify-center gap-2"
>
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'}
</button>
</div>
</form>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useState, useEffect } from 'react';
import type { Product, ProductBundleComponent } from '../../types/Product';
import type { Company } from '../../types/Company';
import { productService } from '../../services/productService';
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface Props {
product: Product | null;
companies: Company[];
allProducts: Product[];
onClose: (refresh?: boolean) => void;
}
const PRODUCT_TYPES = [
{ id: 1, code: 'CLASSIFIED_AD', name: 'Aviso Clasificado' },
{ id: 2, code: 'GRAPHIC_AD', name: 'Publicidad Gráfica' },
{ id: 3, code: 'RADIO_AD', name: 'Publicidad Radial' },
{ id: 4, code: 'PHYSICAL', name: 'Producto Físico' },
{ id: 5, code: 'SERVICE', name: 'Servicio' },
{ id: 6, code: 'BUNDLE', name: 'Paquete Promocional (Combo)' },
];
export default function ProductModal({ product, companies, allProducts, onClose }: Props) {
const [formData, setFormData] = useState<Partial<Product>>({
name: '',
description: '',
companyId: 0,
productTypeId: 4, // Default Physical
basePrice: 0,
taxRate: 21,
sku: '',
isActive: true
});
const [isBundle, setIsBundle] = useState(false);
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
const [newComponentId, setNewComponentId] = useState<number>(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (product) {
setFormData(product);
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
if (type?.code === 'BUNDLE') {
setIsBundle(true);
loadBundleComponents(product.id);
}
}
}, [product]);
useEffect(() => {
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
setIsBundle(type?.code === 'BUNDLE');
}, [formData.productTypeId]);
const loadBundleComponents = async (productId: number) => {
try {
const data = await productService.getBundleComponents(productId);
setBundleComponents(data);
} catch (error) {
console.error("Error cargando componentes", error);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
let productId = product?.id;
if (product) {
await productService.update(product.id, formData);
} else {
const res = await productService.create(formData);
productId = res.id;
}
// Si estamos creando un bundle nuevo y seleccionaron un componente inicial
if (isBundle && productId && newComponentId > 0 && !product) {
await productService.addComponentToBundle(productId, {
childProductId: newComponentId,
quantity: 1
});
}
onClose(true);
} catch (error) {
alert("Error al guardar producto");
} finally {
setLoading(false);
}
};
const addComponentDirectly = async () => {
if (!product || !newComponentId) return;
try {
await productService.addComponentToBundle(product.id, {
childProductId: newComponentId,
quantity: 1
});
setNewComponentId(0);
loadBundleComponents(product.id); // Recargar lista
} catch (e) {
alert("Error agregando componente");
}
};
const removeComponent = async (childId: number) => {
if (!product) return;
if (!confirm("¿Quitar este producto del combo?")) return;
try {
await productService.removeComponentFromBundle(product.id, childId);
loadBundleComponents(product.id);
} catch (e) {
alert("Error eliminando componente");
}
};
return (
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="bg-white w-full max-w-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-100 flex flex-col max-h-[90vh]"
>
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">
{product ? `Editar: ${product.name}` : 'Nuevo Producto'}
</h3>
<button onClick={() => onClose()} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
</div>
<div className="p-8 overflow-y-auto custom-scrollbar">
<form id="productForm" onSubmit={handleSave} className="space-y-6">
{/* TIPO DE PRODUCTO */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Tipo de Producto</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{PRODUCT_TYPES.map(type => (
<div
key={type.id}
onClick={() => setFormData({ ...formData, productTypeId: type.id })}
className={`cursor-pointer p-3 rounded-xl border-2 text-xs font-bold transition-all text-center ${formData.productTypeId === type.id
? 'border-blue-600 bg-blue-50 text-blue-700'
: 'border-slate-100 bg-white text-slate-500 hover:border-slate-300'
}`}
>
{type.name}
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 md: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">Nombre Comercial</label>
<input required type="text" 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={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Empresa Facturadora</label>
<select required 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 appearance-none"
value={formData.companyId} onChange={e => setFormData({ ...formData, companyId: Number(e.target.value) })}
>
<option value="0">Seleccione Empresa...</option>
{companies.map(c => <option key={c.id} value={c.id}>{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">Precio Base ($)</label>
<input required type="number" step="0.01" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm"
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Alicuota IVA (%)</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 appearance-none"
value={formData.taxRate} onChange={e => setFormData({ ...formData, taxRate: parseFloat(e.target.value) })}
>
<option value="21">21% (General)</option>
<option value="10.5">10.5% (Reducido)</option>
<option value="27">27% (Servicios Públicos)</option>
<option value="0">0% (Exento)</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Código SKU</label>
<input type="text" className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-mono font-bold text-sm"
value={formData.sku || ''} onChange={e => setFormData({ ...formData, sku: e.target.value })} />
</div>
</div>
{/* SECCIÓN ESPECIAL PARA BUNDLES */}
<AnimatePresence>
{isBundle && (
<motion.div
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}
className="overflow-hidden bg-purple-50 rounded-2xl border-2 border-purple-100 p-6 space-y-4"
>
<div className="flex items-center gap-2 text-purple-700 font-black uppercase text-xs tracking-widest">
<Layers size={14} /> Contenido del Combo
</div>
<div className="bg-white p-4 rounded-xl border border-purple-100 text-xs text-slate-600">
<AlertCircle size={16} className="inline mr-2 text-purple-500" />
El precio base se prorrateará entre estos componentes al facturar.
</div>
{/* LISTA DE COMPONENTES ACTUALES */}
{bundleComponents.length > 0 && (
<div className="space-y-2 mb-4">
{bundleComponents.map(comp => (
<div key={comp.id} className="flex justify-between items-center bg-white p-3 rounded-lg border border-purple-100 shadow-sm">
<div className="flex items-center gap-3">
<Package size={16} className="text-purple-400" />
<div>
<p className="text-xs font-bold text-slate-700 uppercase">{comp.childProduct?.name}</p>
<p className="text-[10px] text-slate-400">Cantidad: {comp.quantity}</p>
</div>
</div>
<button
type="button"
onClick={() => removeComponent(comp.childProductId)}
className="p-2 text-rose-400 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
{product ? (
<div className="flex gap-2">
<select
className="flex-1 p-2 border border-purple-200 rounded-lg text-xs font-bold text-slate-600 outline-none focus:border-purple-500"
value={newComponentId} onChange={e => setNewComponentId(Number(e.target.value))}
>
<option value="0">Seleccionar producto para agregar...</option>
{allProducts.filter(p => p.id !== product.id && p.typeCode !== 'BUNDLE').map(p => (
<option key={p.id} value={p.id}>{p.name} (Base: ${p.basePrice})</option>
))}
</select>
<button type="button" onClick={addComponentDirectly} className="bg-purple-600 text-white px-4 rounded-lg font-black text-[10px] uppercase tracking-wider hover:bg-purple-700 flex items-center gap-2">
<Plus size={14} /> Agregar
</button>
</div>
) : (
<p className="text-xs text-slate-400 italic text-center py-2">Guarde el producto primero para agregar componentes.</p>
)}
</motion.div>
)}
</AnimatePresence>
</form>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 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
type="submit" form="productForm" disabled={loading}
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"
>
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Producto'}
</button>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,8 +1,13 @@
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import {
LogOut, LayoutDashboard, FolderTree, Users,
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
LogOut, LayoutDashboard, FolderTree, Users, Box,
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag,
Building2,
Calendar,
ArrowRightLeft,
Settings,
ShieldAlert
} from 'lucide-react';
import { useState, useEffect } from 'react';
import api from '../services/api';
@@ -39,63 +44,106 @@ export default function ProtectedLayout() {
}
}, [isAuthenticated, role]);
// Definición de permisos por ruta
const menuItems = [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
{ label: 'Promociones', href: '/promotions', icon: <Tag size={20} />, roles: ['Admin'] },
{ label: 'Cupones', href: '/coupons', icon: <Tag size={20} />, roles: ['Admin'] },
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={20} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={20} />, roles: ['Admin'] },
// Estructura de menú agrupada
const menuGroups = [
{
title: "Gestión Diaria",
items: [
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] },
{ label: 'Moderación', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
{ label: 'Explorador', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] },
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={18} />, roles: ['Admin', 'Cajero'] },
]
},
{
title: "Comercial & Catálogo",
items: [
{ label: 'Productos', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={18} />, roles: ['Admin'] },
{ label: 'Promociones', href: '/promotions', icon: <Tag size={18} />, roles: ['Admin'] },
{ label: 'Cupones', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={18} />, roles: ['Admin'] },
]
},
{
title: "Finanzas & Empresas",
items: [
{ label: 'Empresas', href: '/companies', icon: <Building2 size={18} />, roles: ['Admin'] },
{ label: 'Riesgo Crediticio', href: '/finance/credit', icon: <ShieldAlert size={18} />, roles: ['Admin', 'Gerente'] },
{ label: 'Liquidación', href: '/reports/settlement', icon: <ArrowRightLeft size={18} />, roles: ['Admin', 'Contador'] },
]
},
{
title: "Operaciones & Sistema",
items: [
{ label: 'Diagramación', href: '/diagram', icon: <FileText size={18} />, roles: ['Admin', 'Diagramador'] },
{ label: 'Calendario', href: '/companies/calendar', icon: <Calendar size={18} />, roles: ['Admin'] },
{ label: 'Usuarios', href: '/users', icon: <Users size={18} />, roles: ['Admin'] },
{ label: 'Auditoría', href: '/audit', icon: <History size={18} />, roles: ['Admin'] },
]
}
];
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
<aside className="w-64 bg-gray-900 text-white flex flex-col shadow-xl">
<div className="p-6 text-xl font-black border-b border-gray-800 tracking-tighter italic text-blue-500">
<aside className="w-64 bg-gray-900 text-white flex flex-col shadow-xl flex-shrink-0">
{/* Header Fijo */}
<div className="p-6 text-xl font-black border-b border-gray-800 tracking-tighter italic text-blue-500 flex-shrink-0">
SIG-CM <span className="text-[10px] text-gray-500 not-italic font-medium">ADMIN</span>
</div>
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => {
// FILTRO DE SEGURIDAD UI: Solo mostrar si el rol del usuario está permitido
if (!item.roles.includes(role || '')) return null;
{/* Navegación con Scroll */}
<nav className="flex-1 overflow-y-auto custom-scrollbar px-3 py-4 space-y-6">
{menuGroups.map((group, groupIdx) => {
// Filtrar ítems visibles según rol
const visibleItems = group.items.filter(item => item.roles.includes(role || ''));
// Si no hay ítems visibles en este grupo, no renderizar el título
if (visibleItems.length === 0) return null;
return (
<Link
key={item.href}
to={item.href}
className={`flex items-center justify-between p-3 rounded-xl transition-all ${location.pathname === item.href
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<div className="flex items-center gap-3">
{item.icon}
<span className="font-medium text-sm">{item.label}</span>
<div key={groupIdx}>
<h4 className="px-3 mb-2 text-[10px] font-black uppercase tracking-widest text-gray-500">
{group.title}
</h4>
<div className="space-y-1">
{visibleItems.map((item) => (
<Link
key={item.href}
to={item.href}
className={`flex items-center justify-between px-3 py-2.5 rounded-lg transition-all text-sm ${location.pathname === item.href
? 'bg-blue-600 text-white shadow-md'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<div className="flex items-center gap-3">
{item.icon}
<span className="font-medium">{item.label}</span>
</div>
{(item as any).badge > 0 && (
<span className={`px-1.5 py-0.5 rounded-md text-[9px] font-black ${location.pathname === item.href ? 'bg-white text-blue-600' : 'bg-rose-500 text-white'}`}>
{(item as any).badge}
</span>
)}
</Link>
))}
</div>
{(item as any).badge > 0 && (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black ${location.pathname === item.href ? 'bg-white text-blue-600' : 'bg-rose-500 text-white'}`}>
{(item as any).badge}
</span>
)}
</Link>
</div>
);
})}
</nav>
<div className="p-4 border-t border-gray-800">
<div className="mb-4 px-3 py-2 bg-gray-800/50 rounded-lg">
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Sesión actual</p>
<p className="text-xs font-bold text-blue-400">{role}</p>
{/* Footer Fijo */}
<div className="p-4 border-t border-gray-800 bg-gray-900 flex-shrink-0">
<div className="mb-3 px-3 py-2 bg-gray-800/50 rounded-lg flex items-center justify-between">
<div>
<p className="text-[9px] font-bold text-gray-500 uppercase tracking-widest">Usuario</p>
<p className="text-xs font-bold text-blue-400">{role}</p>
</div>
<Settings size={16} className="text-gray-600" />
</div>
<button onClick={logout} className="flex items-center gap-3 text-red-400 hover:text-red-300 w-full p-2 transition-colors text-sm font-bold">
<LogOut size={18} /> Cerrar Sesión
<button onClick={logout} className="flex items-center gap-3 text-red-400 hover:text-red-300 w-full p-2 transition-colors text-xs font-bold uppercase tracking-wider hover:bg-red-500/10 rounded-lg">
<LogOut size={16} /> Cerrar Sesión
</button>
</div>
</aside>

View File

@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react';
import { calendarService } from '../../services/calendarService';
import { companyService } from '../../services/companyService';
import type { Company } from '../../types/Company';
import type { BlackoutDate } from '../../types/Calendar';
import { Calendar, Trash2, Plus, AlertCircle } from 'lucide-react';
export default function CalendarManager() {
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyId, setSelectedCompanyId] = useState<number>(0);
const [blackouts, setBlackouts] = useState<BlackoutDate[]>([]);
// Formulario
const [newDate, setNewDate] = useState('');
const [reason, setReason] = useState('');
useEffect(() => {
companyService.getAll().then(setCompanies);
}, []);
useEffect(() => {
if (selectedCompanyId) {
loadBlackouts();
}
}, [selectedCompanyId]);
const loadBlackouts = async () => {
const year = new Date().getFullYear();
const data = await calendarService.getByCompany(selectedCompanyId, year);
setBlackouts(data);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCompanyId || !newDate || !reason) return;
try {
await calendarService.add({
companyId: selectedCompanyId,
blackoutDate: newDate,
reason
});
setNewDate('');
setReason('');
loadBlackouts();
} catch {
alert("Error al bloquear fecha (posible duplicado)");
}
};
const handleDelete = async (id: number) => {
if (!confirm("¿Desbloquear fecha?")) return;
await calendarService.delete(id);
loadBlackouts();
};
return (
<div className="max-w-4xl mx-auto space-y-6">
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Calendar className="text-blue-600" /> Calendario Operativo
</h2>
<div className="bg-white p-6 rounded-[2rem] shadow-xl border border-slate-200">
<div className="mb-6">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-2">Seleccione Medio / Empresa</label>
<div className="flex gap-2 overflow-x-auto pb-2">
{companies.map(c => (
<button
key={c.id}
onClick={() => setSelectedCompanyId(c.id)}
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase whitespace-nowrap transition-all border-2 ${selectedCompanyId === c.id
? 'bg-slate-900 text-white border-slate-900'
: 'bg-white text-slate-500 border-slate-100 hover:border-slate-300'
}`}
>
{c.name}
</button>
))}
</div>
</div>
{selectedCompanyId ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* FORMULARIO */}
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-100 h-fit">
<h4 className="text-sm font-black text-slate-700 uppercase mb-4">Bloquear Nueva Fecha</h4>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase">Fecha</label>
<input type="date" required className="w-full p-2 rounded-lg border border-slate-200 font-bold text-sm"
value={newDate} onChange={e => setNewDate(e.target.value)} />
</div>
<div>
<label className="text-[10px] font-bold text-slate-400 uppercase">Motivo</label>
<input type="text" required placeholder="Ej: Feriado Nacional, No Salida"
className="w-full p-2 rounded-lg border border-slate-200 text-sm"
value={reason} onChange={e => setReason(e.target.value)} />
</div>
<button type="submit" className="w-full bg-rose-600 text-white py-3 rounded-xl font-black uppercase text-xs hover:bg-rose-700 transition-all flex justify-center gap-2">
<Plus size={16} /> Bloquear Publicaciones
</button>
</form>
<div className="mt-4 flex gap-2 text-[10px] text-slate-500 bg-white p-3 rounded-lg border border-slate-200">
<AlertCircle size={14} className="text-amber-500 shrink-0" />
El sistema impedirá ventas de avisos que caigan en estas fechas para esta empresa específica.
</div>
</div>
{/* LISTADO */}
<div className="space-y-3">
<h4 className="text-sm font-black text-slate-700 uppercase">Fechas Bloqueadas ({new Date().getFullYear()})</h4>
{blackouts.length === 0 && <p className="text-xs text-slate-400 italic">Calendario operativo normal (sin bloqueos).</p>}
{blackouts.map(b => (
<div key={b.id} className="flex justify-between items-center p-3 bg-white border border-slate-100 rounded-xl shadow-sm hover:border-rose-200 group transition-colors">
<div>
<div className="font-bold text-slate-800 text-sm">
{new Date(b.blackoutDate).toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
<div className="text-[10px] font-black text-rose-500 uppercase tracking-wide">{b.reason}</div>
</div>
<button onClick={() => handleDelete(b.id)} className="p-2 text-slate-300 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-all">
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
) : (
<div className="p-10 text-center text-slate-400 uppercase text-xs font-bold tracking-widest">
Seleccione una empresa para configurar su calendario
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { Building2, Plus, Search, Edit, Trash2, CheckCircle2, XCircle } from 'lucide-react';
import type { Company } from '../../types/Company';
import { companyService } from '../../services/companyService';
import CompanyModal from '../../components/Companies/CompanyModal';
import clsx from 'clsx';
export default function CompanyManager() {
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCompany, setEditingCompany] = useState<Company | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const data = await companyService.getAll();
setCompanies(data);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingCompany(null);
setIsModalOpen(true);
};
const handleEdit = (c: Company) => {
setEditingCompany(c);
setIsModalOpen(true);
};
const handleDelete = async (id: number) => {
if (!confirm('¿Desactivar esta empresa? Esto puede afectar productos vinculados.')) return;
try {
await companyService.delete(id);
loadData();
} catch {
alert('Error al desactivar');
}
};
const filtered = companies.filter(c =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.taxId.includes(searchTerm)
);
return (
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Building2 className="text-blue-600" /> Empresas & Medios
</h2>
<p className="text-sm text-slate-500 font-medium">Gestión de entidades facturadoras y medios de publicación.</p>
</div>
<button
onClick={handleCreate}
className="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-500/20 hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Plus size={16} /> Nueva Empresa
</button>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar por Razón Social o CUIT..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 transition-all font-bold text-sm text-slate-700"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? (
<div className="col-span-full py-20 text-center text-slate-400 font-bold animate-pulse">Cargando empresas...</div>
) : filtered.map(c => (
<div key={c.id} className={clsx("bg-white p-6 rounded-[1.5rem] border-2 transition-all hover:shadow-lg group", c.isActive ? "border-slate-100" : "border-slate-100 opacity-60 grayscale")}>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-slate-50 rounded-2xl text-slate-600 group-hover:bg-blue-50 group-hover:text-blue-600 transition-colors">
<Building2 size={24} />
</div>
<div className="flex gap-1">
<button onClick={() => handleEdit(c)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-slate-50 rounded-lg transition-all"><Edit size={16} /></button>
<button onClick={() => handleDelete(c.id)} className="p-2 text-slate-400 hover:text-rose-600 hover:bg-slate-50 rounded-lg transition-all"><Trash2 size={16} /></button>
</div>
</div>
<h3 className="text-lg font-black text-slate-800 leading-tight mb-1">{c.name}</h3>
<p className="text-sm font-mono font-bold text-slate-500 mb-4">{c.taxId}</p>
<div className="space-y-2 border-t border-slate-50 pt-4">
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-400 uppercase">ID Externo</span>
<span className="font-mono font-bold text-slate-700">{c.externalSystemId || '-'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="font-bold text-slate-400 uppercase">Estado</span>
{c.isActive ? (
<span className="flex items-center gap-1 font-black text-emerald-600 uppercase"><CheckCircle2 size={12} /> Activa</span>
) : (
<span className="flex items-center gap-1 font-black text-slate-400 uppercase"><XCircle size={12} /> Inactiva</span>
)}
</div>
</div>
</div>
))}
</div>
{isModalOpen && (
<CompanyModal company={editingCompany} onClose={() => { setIsModalOpen(false); loadData(); }} />
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { financeService } from '../../services/financeService';
import type { ClientProfile } from '../../types/Finance';
import { ShieldAlert, DollarSign, Save } from 'lucide-react';
import clsx from 'clsx';
export default function CreditManager() {
const [debtors, setDebtors] = useState<ClientProfile[]>([]);
const [selectedClient, setSelectedClient] = useState<ClientProfile | null>(null);
const [loading, setLoading] = useState(true);
// Estado del formulario de edición
const [formData, setFormData] = useState<Partial<ClientProfile>>({});
useEffect(() => {
loadDebtors();
}, []);
const loadDebtors = async () => {
setLoading(true);
try {
const data = await financeService.getDebtors();
setDebtors(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const handleSelect = (client: ClientProfile) => {
setSelectedClient(client);
setFormData({
creditLimit: client.creditLimit,
paymentTermsDays: client.paymentTermsDays,
isCreditBlocked: client.isCreditBlocked,
blockReason: client.blockReason || ''
});
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedClient) return;
try {
await financeService.updateProfile(selectedClient.userId, formData);
alert("Perfil financiero actualizado");
loadDebtors(); // Recargar para ver impacto
setSelectedClient(null);
} catch {
alert("Error al actualizar");
}
};
return (
<div className="max-w-7xl mx-auto space-y-6">
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<DollarSign className="text-emerald-600" /> Gestión de Riesgo Crediticio
</h2>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* LISTA DE DEUDORES / CLIENTES CON CUENTA */}
<div className="bg-white rounded-[1.5rem] shadow-xl border border-slate-200 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-100 bg-slate-50 flex justify-between items-center">
<h3 className="font-bold text-slate-700">Cartera de Clientes Activos</h3>
<button onClick={loadDebtors} className="text-xs text-blue-600 font-bold uppercase hover:underline">Actualizar</button>
</div>
<div className="divide-y divide-slate-100">
{debtors.map(d => (
<div
key={d.userId}
onClick={() => handleSelect(d)}
className={clsx(
"p-4 flex justify-between items-center cursor-pointer hover:bg-blue-50 transition-colors",
selectedClient?.userId === d.userId && "bg-blue-50 border-l-4 border-blue-500"
)}
>
<div>
<div className="font-bold text-slate-800">{d.clientName}</div>
<div className="text-xs text-slate-500 flex items-center gap-2 mt-1">
ID: {d.userId}
{d.isCreditBlocked && <span className="text-[9px] bg-rose-100 text-rose-600 px-2 py-0.5 rounded font-black uppercase">Bloqueado</span>}
</div>
</div>
<div className="text-right">
<div className={clsx("font-mono font-black text-sm", d.currentDebt > d.creditLimit ? "text-rose-600" : "text-emerald-600")}>
${d.currentDebt.toLocaleString()} / ${d.creditLimit.toLocaleString()}
</div>
<div className="text-[10px] font-bold text-slate-400 uppercase">Deuda / Límite</div>
</div>
</div>
))}
{debtors.length === 0 && !loading && (
<div className="p-10 text-center text-slate-400 text-sm">No hay clientes con deuda activa o perfiles configurados.</div>
)}
</div>
</div>
{/* PANEL DE EDICIÓN */}
<div className="bg-white rounded-[1.5rem] shadow-xl border border-slate-200 p-6 h-fit">
{selectedClient ? (
<form onSubmit={handleSave} className="space-y-6">
<div className="border-b border-slate-100 pb-4 mb-4">
<h3 className="font-black text-lg text-slate-800">{selectedClient.clientName}</h3>
<p className="text-xs text-slate-500">Configuración de Crédito</p>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">Límite de Crédito ($)</label>
<input
type="number" className="w-full border-2 border-slate-200 rounded-xl p-3 font-mono font-bold outline-none focus:border-blue-500"
value={formData.creditLimit}
onChange={e => setFormData({ ...formData, creditLimit: parseFloat(e.target.value) })}
/>
</div>
<div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-1">Plazo de Pago (Días)</label>
<input
type="number" className="w-full border-2 border-slate-200 rounded-xl p-3 font-bold outline-none focus:border-blue-500"
value={formData.paymentTermsDays}
onChange={e => setFormData({ ...formData, paymentTermsDays: parseInt(e.target.value) })}
/>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox" className="w-5 h-5 text-rose-600 rounded focus:ring-rose-500"
checked={formData.isCreditBlocked}
onChange={e => setFormData({ ...formData, isCreditBlocked: e.target.checked })}
/>
<span className={clsx("font-black uppercase text-xs", formData.isCreditBlocked ? "text-rose-600" : "text-slate-600")}>
Bloquear Cuenta Corriente
</span>
</label>
{formData.isCreditBlocked && (
<textarea
className="w-full p-2 text-xs border border-rose-200 bg-white rounded-lg outline-none focus:border-rose-500 text-rose-700"
placeholder="Motivo del bloqueo (ej: Legales)"
value={formData.blockReason}
onChange={e => setFormData({ ...formData, blockReason: e.target.value })}
/>
)}
</div>
<button type="submit" className="w-full bg-slate-900 text-white py-3 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-black transition-all flex justify-center gap-2">
<Save size={16} /> Guardar Perfil
</button>
</form>
) : (
<div className="text-center py-10 text-slate-400">
<ShieldAlert size={48} className="mx-auto mb-2 opacity-20" />
<p className="text-xs font-bold uppercase">Seleccione un cliente para gestionar su riesgo</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import { Plus, Search, Edit, Box, Layers } from 'lucide-react';
import { productService } from '../../../../counter-panel/src/services/productService';
import { companyService } from '../../../../counter-panel/src/services/companyService';
import type { Product } from '../../../../counter-panel/src/types/Product';
import type { Company } from '../../../../counter-panel/src/types/Company';
import ProductModal from '../../components/Products/ProductModal';
import clsx from 'clsx';
export default function ProductManager() {
const [products, setProducts] = useState<Product[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
// Estado para el Modal
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [prodRes, compRes] = await Promise.all([
productService.getAll(),
companyService.getAll()
]);
setProducts(prodRes);
setCompanies(compRes);
} catch (error) {
console.error("Error cargando catálogo", error);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingProduct(null);
setIsModalOpen(true);
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
setIsModalOpen(true);
};
const handleModalClose = (shouldRefresh = false) => {
setIsModalOpen(false);
if (shouldRefresh) loadData();
};
const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.sku?.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<Box className="text-blue-600" /> Catálogo Maestro
</h2>
<p className="text-sm text-slate-500 font-medium">Gestión de productos físicos, servicios y combos multimedio.</p>
</div>
<button
onClick={handleCreate}
className="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-500/20 hover:bg-blue-700 transition-all flex items-center gap-2"
>
<Plus size={16} /> Nuevo Producto
</button>
</div>
{/* Buscador */}
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Buscar por Nombre, SKU o Código..."
className="w-full pl-10 pr-4 py-2 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 transition-all font-bold text-sm text-slate-700"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Tabla */}
<div className="bg-white rounded-[1.5rem] border border-slate-200 shadow-xl overflow-hidden">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50 text-[10px] font-black text-slate-500 uppercase tracking-widest border-b border-slate-100">
<tr>
<th className="p-5">Detalle Producto</th>
<th className="p-5">Tipo & Empresa</th>
<th className="p-5 text-right">Precio Base</th>
<th className="p-5 text-center">Impuesto</th>
<th className="p-5 text-center">Estado</th>
<th className="p-5 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 text-sm">
{loading ? (
<tr><td colSpan={6} className="p-10 text-center text-slate-400 font-bold animate-pulse">Cargando catálogo...</td></tr>
) : filteredProducts.map(p => (
<tr key={p.id} className="hover:bg-blue-50/30 transition-all group">
<td className="p-5">
<div className="font-extrabold text-slate-800 uppercase tracking-tight">{p.name}</div>
<div className="text-[10px] font-bold text-slate-400 font-mono mt-0.5">SKU: {p.sku || 'N/A'}</div>
</td>
<td className="p-5">
<div className="flex flex-col gap-1">
<span className={clsx(
"px-2 py-0.5 rounded text-[9px] font-black uppercase w-fit border",
p.typeCode === 'BUNDLE' ? "bg-purple-50 text-purple-700 border-purple-100" :
p.typeCode === 'CLASSIFIED_AD' ? "bg-blue-50 text-blue-700 border-blue-100" :
"bg-slate-100 text-slate-600 border-slate-200"
)}>
{p.typeCode === 'BUNDLE' ? <span className="flex items-center gap-1"><Layers size={10} /> COMBO</span> : p.typeCode}
</span>
<span className="text-[10px] font-bold text-slate-500">{p.companyName}</span>
</div>
</td>
<td className="p-5 text-right font-mono font-black text-slate-900">
$ {p.basePrice.toLocaleString()}
</td>
<td className="p-5 text-center text-xs font-bold text-slate-600">
{p.taxRate}%
</td>
<td className="p-5 text-center">
<span className={clsx("w-2 h-2 rounded-full inline-block", p.isActive ? "bg-emerald-500" : "bg-rose-500")}></span>
</td>
<td className="p-5 text-right">
<button
onClick={() => handleEdit(p)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Edit size={18} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{isModalOpen && (
<ProductModal
product={editingProduct}
companies={companies}
allProducts={products} // Pasamos todos los productos para poder armar combos
onClose={handleModalClose}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { financeService } from '../../services/financeService';
import type { SettlementItem } from '../../types/Finance';
import { ArrowRightLeft, Search } from 'lucide-react';
export default function SettlementReport() {
const [data, setData] = useState<SettlementItem[]>([]);
const [dates, setDates] = useState({
from: new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0],
to: new Date().toISOString().split('T')[0]
});
const [loading, setLoading] = useState(false);
const loadData = async () => {
setLoading(true);
try {
const res = await financeService.getSettlementReport(dates.from, dates.to);
setData(res);
} catch {
console.error("Error cargando reporte");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-5xl mx-auto space-y-6">
<h2 className="text-2xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
<ArrowRightLeft className="text-purple-600" /> Liquidación Cruzada
</h2>
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-200 flex items-end gap-4">
<div className="flex-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1 block">Rango de Fechas</label>
<div className="flex items-center gap-2">
<input type="date" value={dates.from} onChange={e => setDates({ ...dates, from: e.target.value })} className="border p-2 rounded-lg text-sm font-bold w-full" />
<span className="text-slate-300">-</span>
<input type="date" value={dates.to} onChange={e => setDates({ ...dates, to: e.target.value })} className="border p-2 rounded-lg text-sm font-bold w-full" />
</div>
</div>
<button onClick={loadData} className="bg-slate-900 text-white px-6 py-2.5 rounded-xl font-black uppercase text-xs tracking-widest hover:bg-black transition-all flex items-center gap-2 h-[42px]">
<Search size={14} /> Generar Reporte
</button>
</div>
<div className="bg-white rounded-[2rem] shadow-xl border border-slate-200 overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 text-[10px] font-black text-slate-500 uppercase tracking-widest border-b border-slate-100">
<tr>
<th className="p-6">Empresa Facturadora (Cobró)</th>
<th className="p-6">Empresa Prestadora (Servicio)</th>
<th className="p-6 text-center">Transacciones</th>
<th className="p-6 text-right">Monto a Rendir</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{loading ? (
<tr><td colSpan={4} className="p-10 text-center animate-pulse text-xs font-bold text-slate-400">Calculando liquidaciones...</td></tr>
) : data.length === 0 ? (
<tr><td colSpan={4} className="p-10 text-center text-xs font-bold text-slate-400">No hay movimientos cruzados en este período.</td></tr>
) : data.map((item, idx) => (
<tr key={idx} className="hover:bg-purple-50/30 transition-colors">
<td className="p-6 font-bold text-slate-800">{item.billingCompany}</td>
<td className="p-6 font-bold text-slate-800">{item.serviceCompany}</td>
<td className="p-6 text-center">
<span className="bg-slate-100 text-slate-600 px-3 py-1 rounded-full text-xs font-bold">{item.transactionCount}</span>
</td>
<td className="p-6 text-right">
<span className="font-mono font-black text-lg text-purple-600">$ {item.totalAmount.toLocaleString()}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import api from './api';
import type { BlackoutDate } from '../types/Calendar';
export const calendarService = {
getByCompany: async (companyId: number, year: number) => {
const res = await api.get<BlackoutDate[]>(`/calendar/${companyId}/${year}`);
return res.data;
},
add: async (data: Partial<BlackoutDate>) => {
await api.post('/calendar', data);
},
delete: async (id: number) => {
await api.delete(`/calendar/${id}`);
}
};

View File

@@ -0,0 +1,22 @@
import api from './api';
import type { Company } from '../types/Company';
export const companyService = {
getAll: async (): Promise<Company[]> => {
const response = await api.get<Company[]>('/companies');
return response.data;
},
create: async (company: Partial<Company>): Promise<Company> => {
const response = await api.post<Company>('/companies', company);
return response.data;
},
update: async (id: number, company: Partial<Company>): Promise<void> => {
await api.put(`/companies/${id}`, company);
},
delete: async (id: number): Promise<void> => {
await api.delete(`/companies/${id}`);
}
};

View File

@@ -0,0 +1,25 @@
import api from './api';
import type { ClientProfile, SettlementItem } from '../types/Finance';
export const financeService = {
getClientStatus: async (clientId: number) => {
const res = await api.get<ClientProfile>(`/finance/client/${clientId}`);
return res.data;
},
updateProfile: async (clientId: number, profile: Partial<ClientProfile>) => {
await api.post(`/finance/client/${clientId}`, profile);
},
getDebtors: async () => {
const res = await api.get<ClientProfile[]>('/finance/debtors');
return res.data;
},
getSettlementReport: async (from: string, to: string) => {
const res = await api.get<SettlementItem[]>('/reports/inter-company-settlement', {
params: { from, to }
});
return res.data;
}
};

View 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;
}
};

View File

@@ -0,0 +1,6 @@
export interface BlackoutDate {
id: number;
companyId: number;
blackoutDate: string; // ISO Date
reason: string;
}

View File

@@ -0,0 +1,9 @@
export interface Company {
id: number;
name: string;
taxId: string; // CUIT
legalAddress?: string;
logoUrl?: string;
externalSystemId?: string;
isActive: boolean;
}

View File

@@ -0,0 +1,17 @@
export interface ClientProfile {
userId: number;
creditLimit: number;
paymentTermsDays: number;
isCreditBlocked: boolean;
blockReason?: string;
lastCreditCheckAt: string;
currentDebt: number; // Calculado
clientName?: string;
}
export interface SettlementItem {
billingCompany: string;
serviceCompany: string;
transactionCount: number;
totalAmount: number;
}

View 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;
}

View File

@@ -24,5 +24,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src", "src/pages/Products"]
}

View File

@@ -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 />} />

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}
}

View File

@@ -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' },

View 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>
);
}

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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)
}));

View File

@@ -0,0 +1,9 @@
export interface Company {
id: number;
name: string;
taxId: string; // CUIT
legalAddress?: string;
logoUrl?: string;
externalSystemId?: string;
isActive: boolean;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,7 @@
export interface SellerProfile {
userId: number;
sellerCode?: string;
baseCommissionPercentage: number;
isActive: boolean;
username?: string; // Para mostrar el nombre
}

View File

@@ -24,5 +24,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"include": ["src", "../admin-panel/src/pages/Products"]
}

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin,Diagramador")]
public class CalendarController : ControllerBase
{
private readonly ICalendarRepository _repo;
public CalendarController(ICalendarRepository repo)
{
_repo = repo;
}
[HttpGet("{companyId}/{year}")]
public async Task<IActionResult> GetBlackouts(int companyId, int year)
{
var dates = await _repo.GetBlackoutsAsync(companyId, year);
return Ok(dates);
}
[HttpPost]
public async Task<IActionResult> AddBlackout(PublicationBlackout blackout)
{
await _repo.AddBlackoutAsync(blackout);
return Ok();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteBlackout(int id)
{
await _repo.DeleteBlackoutAsync(id);
return NoContent();
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
namespace SIGCM.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Admin")] // Solo Admins gestionan empresas
public class CompaniesController : ControllerBase
{
private readonly ICompanyRepository _repo;
public CompaniesController(ICompanyRepository repo)
{
_repo = repo;
}
[HttpGet]
public async Task<IActionResult> GetAll()
{
var companies = await _repo.GetAllAsync();
return Ok(companies);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var company = await _repo.GetByIdAsync(id);
if (company == null) return NotFound();
return Ok(company);
}
[HttpPost]
public async Task<IActionResult> Create(Company company)
{
var id = await _repo.CreateAsync(company);
return Ok(new { id });
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Company company)
{
if (id != company.Id) return BadRequest();
await _repo.UpdateAsync(company);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
await _repo.DeleteAsync(id);
return NoContent();
}
}

View File

@@ -99,4 +99,23 @@ public class ProductsController : ControllerBase
await _repository.RemoveComponentFromBundleAsync(bundleId, childId);
return NoContent();
}
[HttpGet("{id}/components")]
public async Task<IActionResult> GetBundleComponents(int id)
{
var components = await _repository.GetBundleComponentsAsync(id);
// Mapeamos a anónimo o DTO para limpiar la respuesta JSON si es necesario
var result = components.Select(c => new
{
c.Id, // ID de la relación
c.ParentProductId,
c.ChildProductId,
c.Quantity,
c.FixedAllocationAmount,
ChildProduct = c.ChildProduct // El objeto producto completo anidado
});
return Ok(result);
}
}

View File

@@ -1,4 +1,3 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM.Domain.Interfaces;
@@ -14,11 +13,13 @@ public class ReportsController : ControllerBase
{
private readonly IListingRepository _listingRepo;
private readonly AuditRepository _auditRepo;
private readonly IReportRepository _reportRepo;
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo)
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo, IReportRepository reportRepo)
{
_listingRepo = listingRepo;
_auditRepo = auditRepo;
_reportRepo = reportRepo;
}
[HttpGet("dashboard")]
@@ -148,4 +149,16 @@ public class ReportsController : ControllerBase
var cajeros = await _listingRepo.GetActiveCashiersAsync();
return Ok(cajeros);
}
[HttpGet("inter-company-settlement")]
[Authorize(Roles = "Admin,Contador")]
public async Task<IActionResult> GetSettlementReport([FromQuery] DateTime? from, [FromQuery] DateTime? to)
{
// Fechas por defecto: Mes actual
var startDate = from ?? new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
var endDate = to ?? DateTime.Now;
var result = await _reportRepo.GetInterCompanySettlementAsync(startDate, endDate);
return Ok(result);
}
}

View File

@@ -2,23 +2,20 @@ namespace SIGCM.Domain.Entities;
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public int CompanyId { get; set; } // Para facturación cruzada
// Vinculación polimórfica (Ej: ID del Listing que se acaba de crear)
public int? RelatedEntityId { get; set; }
public string? RelatedEntityType { get; set; } // 'Listing', 'Merchandise', 'RadioSpot'
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TaxRate { get; set; }
public decimal SubTotal { get; set; }
public decimal CommissionPercentage { get; set; }
public decimal CommissionAmount { get; set; }
// Auxiliar
public string? ProductName { get; set; }
public int Id { get; set; }
public int OrderId { get; set; }
public int ProductId { get; set; }
public int ServiceCompanyId { get; set; } // El que publica
public int BillingCompanyId { get; set; } // El que factura
public int? RelatedEntityId { get; set; }
public string? RelatedEntityType { get; set; }
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TaxRate { get; set; }
public decimal SubTotal { get; set; }
public decimal CommissionPercentage { get; set; }
public decimal CommissionAmount { get; set; }
public string? ProductName { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace SIGCM.Domain.Entities;
public class ProductPrice
{
public int Id { get; set; }
public int ProductId { get; set; }
public decimal Price { get; set; }
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
public int CreatedByUserId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,9 @@
namespace SIGCM.Domain.Entities;
public class PublicationBlackout
{
public int Id { get; set; }
public int CompanyId { get; set; }
public DateTime BlackoutDate { get; set; }
public required string Reason { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM.Domain.Interfaces;
using SIGCM.Domain.Entities;
public interface ICalendarRepository
{
Task AddBlackoutAsync(PublicationBlackout blackout);
Task DeleteBlackoutAsync(int id);
Task<IEnumerable<PublicationBlackout>> GetBlackoutsAsync(int companyId, int year);
Task<bool> IsDateBlockedAsync(int companyId, DateTime date);
}

View File

@@ -0,0 +1,12 @@
using SIGCM.Domain.Entities;
namespace SIGCM.Domain.Interfaces;
public interface ICompanyRepository
{
Task<IEnumerable<Company>> GetAllAsync();
Task<Company?> GetByIdAsync(int id);
Task<int> CreateAsync(Company company);
Task UpdateAsync(Company company);
Task DeleteAsync(int id); // Soft delete (IsActive = 0)
}

View File

@@ -13,4 +13,6 @@ public interface IProductRepository
Task<IEnumerable<ProductBundle>> GetBundleComponentsAsync(int parentProductId);
Task AddComponentToBundleAsync(ProductBundle bundle);
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
Task<decimal> GetCurrentPriceAsync(int productId, DateTime date);
Task AddPriceAsync(ProductPrice price);
}

View File

@@ -0,0 +1,14 @@
namespace SIGCM.Domain.Interfaces;
public class SettlementItem
{
public string BillingCompany { get; set; } = string.Empty;
public string ServiceCompany { get; set; } = string.Empty;
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}
public interface IReportRepository
{
Task<IEnumerable<SettlementItem>> GetInterCompanySettlementAsync(DateTime from, DateTime to);
}

View File

@@ -40,6 +40,9 @@ public static class DependencyInjection
services.AddScoped<IAdvertisingRepository, AdvertisingRepository>();
services.AddScoped<ISellerRepository, SellerRepository>();
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
services.AddScoped<ICompanyRepository, CompanyRepository>();
services.AddScoped<IReportRepository, ReportRepository>();
services.AddScoped<ICalendarRepository, CalendarRepository>();;
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
services.AddScoped<MercadoPagoService>(sp =>

View File

@@ -0,0 +1,43 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CalendarRepository : ICalendarRepository
{
private readonly IDbConnectionFactory _db;
public CalendarRepository(IDbConnectionFactory db) => _db = db;
public async Task AddBlackoutAsync(PublicationBlackout blackout)
{
using var conn = _db.CreateConnection();
await conn.ExecuteAsync(
"INSERT INTO PublicationBlackouts (CompanyId, BlackoutDate, Reason) VALUES (@CompanyId, @BlackoutDate, @Reason)",
blackout);
}
public async Task DeleteBlackoutAsync(int id)
{
using var conn = _db.CreateConnection();
await conn.ExecuteAsync("DELETE FROM PublicationBlackouts WHERE Id = @Id", new { Id = id });
}
public async Task<IEnumerable<PublicationBlackout>> GetBlackoutsAsync(int companyId, int year)
{
using var conn = _db.CreateConnection();
return await conn.QueryAsync<PublicationBlackout>(
"SELECT * FROM PublicationBlackouts WHERE CompanyId = @CompanyId AND YEAR(BlackoutDate) = @Year ORDER BY BlackoutDate",
new { CompanyId = companyId, Year = year });
}
public async Task<bool> IsDateBlockedAsync(int companyId, DateTime date)
{
using var conn = _db.CreateConnection();
var count = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(1) FROM PublicationBlackouts WHERE CompanyId = @CompanyId AND BlackoutDate = @Date",
new { CompanyId = companyId, Date = date.Date });
return count > 0;
}
}

View File

@@ -0,0 +1,53 @@
using Dapper;
using SIGCM.Domain.Entities;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class CompanyRepository : ICompanyRepository
{
private readonly IDbConnectionFactory _db;
public CompanyRepository(IDbConnectionFactory db) => _db = db;
public async Task<IEnumerable<Company>> GetAllAsync()
{
using var conn = _db.CreateConnection();
// Traemos todas para el admin, ordenadas por nombre
return await conn.QueryAsync<Company>("SELECT * FROM Companies ORDER BY Name");
}
public async Task<Company?> GetByIdAsync(int id)
{
using var conn = _db.CreateConnection();
return await conn.QuerySingleOrDefaultAsync<Company>("SELECT * FROM Companies WHERE Id = @Id", new { Id = id });
}
public async Task<int> CreateAsync(Company company)
{
using var conn = _db.CreateConnection();
var sql = @"
INSERT INTO Companies (Name, TaxId, LegalAddress, LogoUrl, ExternalSystemId, IsActive)
VALUES (@Name, @TaxId, @LegalAddress, @LogoUrl, @ExternalSystemId, @IsActive);
SELECT CAST(SCOPE_IDENTITY() as int);";
return await conn.QuerySingleAsync<int>(sql, company);
}
public async Task UpdateAsync(Company company)
{
using var conn = _db.CreateConnection();
var sql = @"
UPDATE Companies
SET Name = @Name, TaxId = @TaxId, LegalAddress = @LegalAddress,
LogoUrl = @LogoUrl, ExternalSystemId = @ExternalSystemId, IsActive = @IsActive
WHERE Id = @Id";
await conn.ExecuteAsync(sql, company);
}
public async Task DeleteAsync(int id)
{
using var conn = _db.CreateConnection();
// Soft delete para mantener integridad referencial con productos/ventas históricas
await conn.ExecuteAsync("UPDATE Companies SET IsActive = 0 WHERE Id = @Id", new { Id = id });
}
}

View File

@@ -126,4 +126,39 @@ public class ProductRepository : IProductRepository
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId",
new { ParentId = bundleId, ChildId = childProductId });
}
// Obtener el precio vigente para una fecha dada
public async Task<decimal> GetCurrentPriceAsync(int productId, DateTime date)
{
using var conn = _db.CreateConnection();
// Buscamos el precio específico vigente. Si no hay, fallback al BasePrice del producto.
var sql = @"
SELECT TOP 1 Price
FROM ProductPrices
WHERE ProductId = @ProductId
AND ValidFrom <= @Date
AND (ValidTo IS NULL OR ValidTo >= @Date)
ORDER BY ValidFrom DESC";
var specificPrice = await conn.ExecuteScalarAsync<decimal?>(sql, new { ProductId = productId, Date = date });
if (specificPrice.HasValue) return specificPrice.Value;
// Fallback
return await conn.ExecuteScalarAsync<decimal>("SELECT BasePrice FROM Products WHERE Id = @Id", new { Id = productId });
}
// Crear nuevo precio histórico
public async Task AddPriceAsync(ProductPrice price)
{
using var conn = _db.CreateConnection();
// Opcional: Cerrar vigencia del precio anterior automáticamente
await conn.ExecuteAsync(
"UPDATE ProductPrices SET ValidTo = GETUTCDATE() WHERE ProductId = @ProductId AND ValidTo IS NULL",
new { price.ProductId });
var sql = @"INSERT INTO ProductPrices (ProductId, Price, ValidFrom, ValidTo, CreatedByUserId)
VALUES (@ProductId, @Price, @ValidFrom, @ValidTo, @CreatedByUserId)";
await conn.ExecuteAsync(sql, price);
}
}

View File

@@ -0,0 +1,37 @@
using Dapper;
using SIGCM.Domain.Interfaces;
using SIGCM.Infrastructure.Data;
namespace SIGCM.Infrastructure.Repositories;
public class ReportRepository : IReportRepository
{
private readonly IDbConnectionFactory _db;
public ReportRepository(IDbConnectionFactory db)
{
_db = db;
}
public async Task<IEnumerable<SettlementItem>> GetInterCompanySettlementAsync(DateTime from, DateTime to)
{
using var conn = _db.CreateConnection();
var sql = @"
SELECT
b.Name as BillingCompany,
s.Name as ServiceCompany,
COUNT(*) as TransactionCount,
SUM(oi.SubTotal) as TotalAmount
FROM OrderItems oi
JOIN Orders o ON oi.OrderId = o.Id
JOIN Companies b ON oi.BillingCompanyId = b.Id
JOIN Companies s ON oi.ServiceCompanyId = s.Id
WHERE oi.BillingCompanyId <> oi.ServiceCompanyId
AND o.PaymentStatus = 'Paid' -- Solo liquidamos dinero efectivamente cobrado
AND o.CreatedAt >= @From AND o.CreatedAt <= @To
GROUP BY b.Name, s.Name
ORDER BY b.Name, s.Name";
return await conn.QueryAsync<SettlementItem>(sql, new { From = from, To = to });
}
}

View File

@@ -11,32 +11,28 @@ public class OrderService : IOrderService
private readonly IProductRepository _productRepo;
private readonly ISellerRepository _sellerRepo;
private readonly IClientProfileRepository _clientRepo;
private readonly ICalendarRepository _calendarRepo;
private readonly IListingRepository _listingRepo;
public OrderService(IOrderRepository orderRepo, IProductRepository productRepo, ISellerRepository sellerRepo, IClientProfileRepository clientRepo)
public OrderService(
IOrderRepository orderRepo,
IProductRepository productRepo,
ISellerRepository sellerRepo,
IClientProfileRepository clientRepo,
ICalendarRepository calendarRepo,
IListingRepository listingRepo)
{
_orderRepo = orderRepo;
_productRepo = productRepo;
_sellerRepo = sellerRepo;
_clientRepo = clientRepo;
_calendarRepo = calendarRepo;
_listingRepo = listingRepo;
}
public async Task<OrderResultDto> CreateOrderAsync(CreateOrderDto dto)
{
// 1. Preparar la cabecera
var order = new Order
{
OrderNumber = GenerateOrderNumber(),
ClientId = dto.ClientId,
SellerId = dto.SellerId,
CreatedAt = DateTime.UtcNow,
DueDate = dto.DueDate,
Notes = dto.Notes,
// Estado inicial: Si es pago directo -> 'Paid', sino 'Pending'
PaymentStatus = dto.IsDirectPayment ? "Paid" : "Pending",
FulfillmentStatus = "Pending"
};
// Obtener perfil del vendedor para saber su % de comisión
// 1. OBTENER CONFIGURACIÓN DEL VENDEDOR
decimal sellerCommissionRate = 0;
var sellerProfile = await _sellerRepo.GetProfileAsync(dto.SellerId);
if (sellerProfile != null && sellerProfile.IsActive)
@@ -44,179 +40,221 @@ public class OrderService : IOrderService
sellerCommissionRate = sellerProfile.BaseCommissionPercentage;
}
// --- VALIDACIÓN DE CRÉDITO ---
if (!dto.IsDirectPayment) // Si es Cuenta Corriente
// 2. VALIDACIÓN DE CRÉDITO (Si no es pago directo)
if (!dto.IsDirectPayment)
{
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
// 1. Verificar si existe perfil financiero
if (profile == null)
{
// Opción A: Bloquear si no tiene perfil (Estricto)
// Opción B: Permitir si el sistema asume límite 0 o default (Flexible)
// Vamos con opción Estricta para obligar al alta administrativa
throw new InvalidOperationException("El cliente no tiene habilitada la Cuenta Corriente (Falta Perfil Financiero).");
}
throw new InvalidOperationException("El cliente no tiene habilitada la Cuenta Corriente.");
// 2. Verificar bloqueo manual (Legales, Mora, etc)
if (profile.IsCreditBlocked)
{
throw new InvalidOperationException($"Crédito BLOQUEADO para este cliente. Motivo: {profile.BlockReason}");
}
// 3. Calcular Deuda Actual + Nueva Orden
// Nota: Necesitamos calcular el total de la orden actual ANTES de validar
// (Para no duplicar código, calculamos totales primero y validamos antes de guardar)
throw new InvalidOperationException($"Crédito BLOQUEADO. Motivo: {profile.BlockReason}");
}
// 3. PROCESAMIENTO DE ÍTEMS
var orderItems = new List<OrderItem>();
decimal totalNet = 0;
decimal totalTax = 0;
// 2. Procesar ítems y calcular montos reales
foreach (var itemDto in dto.Items)
{
var product = await _productRepo.GetByIdAsync(itemDto.ProductId);
if (product == null) throw new Exception($"Producto {itemDto.ProductId} no encontrado.");
if (product == null || !product.IsActive)
throw new Exception($"Producto ID {itemDto.ProductId} no encontrado o inactivo.");
// DETECCIÓN: ¿Es un Combo?
// A. VALIDACIÓN DE CALENDARIO (Bloqueos de fecha)
if (itemDto.RelatedEntityType == "Listing" && itemDto.RelatedEntityId.HasValue)
{
var listing = await _listingRepo.GetByIdAsync(itemDto.RelatedEntityId.Value);
if (listing != null && listing.PublicationStartDate.HasValue)
{
// Validar cada día de publicación contra el calendario de la empresa dueña del producto
int daysToCheck = listing.PrintDaysCount > 0 ? listing.PrintDaysCount : 1;
for (int i = 0; i < daysToCheck; i++)
{
var checkDate = listing.PublicationStartDate.Value.AddDays(i);
bool isBlocked = await _calendarRepo.IsDateBlockedAsync(product.CompanyId, checkDate);
if (isBlocked)
{
throw new InvalidOperationException(
$"La fecha {checkDate:dd/MM/yyyy} no está habilitada para publicaciones en {product.CompanyName}.");
}
}
}
}
// B. OBTENER PRECIO VIGENTE (Histórico)
// Usamos la fecha actual para determinar el precio.
decimal currentUnitPrice = await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
// C. LÓGICA DE COMBOS VS SIMPLE
if (product.TypeCode == "BUNDLE")
{
// 1. Obtener componentes
// --- LÓGICA DE SETTLEMENT (Facturación Cruzada) ---
var components = await _productRepo.GetBundleComponentsAsync(product.Id);
if (!components.Any()) throw new Exception($"El combo {product.Name} no tiene componentes definidos.");
if (!components.Any()) throw new Exception($"El combo {product.Name} no tiene configuración válida.");
// 2. Calcular totales base para prorrateo
// El precio al que vendemos el combo (ej: $1000)
decimal bundleSellPrice = product.BasePrice;
// La empresa que factura es la dueña del Combo
int billingCompanyId = product.CompanyId;
// La suma de los precios de lista de los componentes (ej: $600 + $600 = $1200)
decimal totalComponentsBasePrice = components.Sum(c => c.ChildProduct!.BasePrice * c.Quantity);
// Calcular suma de precios vigentes de los hijos para prorrateo
decimal totalComponentsCurrentPrice = 0;
foreach (var c in components)
{
decimal childPrice = await _productRepo.GetCurrentPriceAsync(c.ChildProductId, DateTime.UtcNow);
totalComponentsCurrentPrice += childPrice * c.Quantity;
}
if (totalComponentsBasePrice == 0) totalComponentsBasePrice = 1; // Evitar div/0
if (totalComponentsCurrentPrice == 0) totalComponentsCurrentPrice = 1; // Evitar div/0
// 3. Iterar componentes y crear ítems individuales ("Explosión")
// Iterar hijos y crear líneas
foreach (var comp in components)
{
var child = comp.ChildProduct!;
decimal childCurrentPrice = await _productRepo.GetCurrentPriceAsync(child.Id, DateTime.UtcNow);
// Lógica de Prorrateo:
// (PrecioBaseHijo / PrecioBaseTotalHijos) * PrecioVentaCombo
// Ej: (600 / 1200) * 1000 = $500
// 1. Calcular Precio Prorrateado
decimal allocatedUnitPrice;
if (comp.FixedAllocationAmount.HasValue)
{
allocatedUnitPrice = comp.FixedAllocationAmount.Value;
}
else
{
decimal ratio = child.BasePrice / totalComponentsBasePrice;
allocatedUnitPrice = bundleSellPrice * ratio;
// Regla de 3 simple sobre el precio del combo
decimal ratio = childCurrentPrice / totalComponentsCurrentPrice;
allocatedUnitPrice = currentUnitPrice * ratio;
}
// Calcular línea para el componente
decimal qty = itemDto.Quantity * comp.Quantity; // Cantidad pedida * Cantidad en combo
decimal subTotal = allocatedUnitPrice * qty;
decimal taxAmount = subTotal * (child.TaxRate / 100m);
// 2. Calcular Totales de Línea
decimal qty = itemDto.Quantity * comp.Quantity;
decimal subTotalLine = allocatedUnitPrice * qty;
decimal taxAmountLine = subTotalLine * (child.TaxRate / 100m);
decimal commissionLine = subTotalLine * (sellerCommissionRate / 100m);
totalNet += subTotal;
totalTax += taxAmount;
// 3. Acumular a Totales de Orden
totalNet += subTotalLine;
totalTax += taxAmountLine;
// 4. Crear Item
orderItems.Add(new OrderItem
{
ProductId = child.Id,
CompanyId = child.CompanyId,
BillingCompanyId = billingCompanyId, // Cobra EL DÍA
ServiceCompanyId = child.CompanyId, // Presta EL PLATA
Quantity = qty,
UnitPrice = allocatedUnitPrice,
TaxRate = child.TaxRate,
SubTotal = subTotal + taxAmount,
SubTotal = subTotalLine + taxAmountLine,
CommissionPercentage = sellerCommissionRate,
CommissionAmount = commissionLine,
RelatedEntityId = itemDto.RelatedEntityId,
RelatedEntityType = itemDto.RelatedEntityType,
ProductName = $"{product.Name} > {child.Name}" // Traza para saber que vino de un combo
// La comisión se calculará después si es necesario
ProductName = $"{product.Name} > {child.Name}" // Traza visual
});
}
}
else
{
// --- LÓGICA ESTÁNDAR (PRODUCTO NORMAL) ---
decimal unitPrice = product.BasePrice;
decimal subTotalNet = unitPrice * itemDto.Quantity;
decimal taxAmount = subTotalNet * (product.TaxRate / 100m);
// --- PRODUCTO SIMPLE ---
decimal subTotalLine = currentUnitPrice * itemDto.Quantity;
decimal taxAmountLine = subTotalLine * (product.TaxRate / 100m);
decimal commissionLine = subTotalLine * (sellerCommissionRate / 100m);
totalNet += subTotalNet;
totalTax += taxAmount;
// Cálculo Comisión Vendedor
decimal commissionAmt = subTotalNet * (sellerCommissionRate / 100m);
totalNet += subTotalLine;
totalTax += taxAmountLine;
orderItems.Add(new OrderItem
{
ProductId = product.Id,
CompanyId = product.CompanyId,
BillingCompanyId = product.CompanyId, // Cobra el mismo que presta
ServiceCompanyId = product.CompanyId,
Quantity = itemDto.Quantity,
UnitPrice = unitPrice,
UnitPrice = currentUnitPrice,
TaxRate = product.TaxRate,
SubTotal = subTotalNet + taxAmount,
SubTotal = subTotalLine + taxAmountLine,
CommissionPercentage = sellerCommissionRate,
CommissionAmount = commissionLine,
RelatedEntityId = itemDto.RelatedEntityId,
RelatedEntityType = itemDto.RelatedEntityType,
CommissionPercentage = sellerCommissionRate,
CommissionAmount = commissionAmt,
ProductName = product.Name
});
}
}
// 3. Finalizar totales de cabecera
order.TotalNet = totalNet;
order.TotalTax = totalTax;
order.TotalAmount = totalNet + totalTax;
// 4. VALIDACIÓN DE LÍMITE DE CRÉDITO (CHECK FINAL)
var totalOrderAmount = totalNet + totalTax;
// --- VALIDACIÓN DE LÍMITE (Post-Cálculo) ---
if (!dto.IsDirectPayment)
{
var currentDebt = await _clientRepo.CalculateCurrentDebtAsync(dto.ClientId);
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
// Asumimos profile != null porque validamos arriba, pero por seguridad:
if (profile != null && (currentDebt + totalOrderAmount) > profile.CreditLimit)
{
throw new InvalidOperationException(
$"Operación rechazada. Límite excedido. " +
$"Límite: ${profile.CreditLimit:N2} | Deuda: ${currentDebt:N2} | Orden: ${totalOrderAmount:N2}");
}
}
// 5. CONSTRUIR CABECERA
var order = new Order
{
// OrderNumber se genera en el repo con secuencia SQL
OrderNumber = "TEMP",
ClientId = dto.ClientId,
SellerId = dto.SellerId,
CreatedAt = DateTime.UtcNow,
// Si es Cta Cte y no viene fecha, usar default del perfil
DueDate = dto.DueDate ?? (dto.IsDirectPayment ? null : DateTime.UtcNow.AddDays(15)),
Notes = dto.Notes,
PaymentStatus = dto.IsDirectPayment ? "Paid" : "Pending",
FulfillmentStatus = "Pending",
TotalNet = totalNet,
TotalTax = totalTax,
TotalAmount = totalOrderAmount
};
// Si es Cta Cte, buscar días de vencimiento específicos del perfil
if (!dto.IsDirectPayment && !dto.DueDate.HasValue)
{
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
if (profile != null)
{
var currentDebt = await _clientRepo.CalculateCurrentDebtAsync(dto.ClientId);
var totalAmount = totalNet + totalTax;
var newTotalDebt = currentDebt + totalAmount;
order.DueDate = DateTime.UtcNow.AddDays(profile.PaymentTermsDays);
}
}
if (newTotalDebt > profile.CreditLimit)
{
// AQUÍ OCURRE EL "CORTE DE VENTAS"
throw new InvalidOperationException(
$"Límite de crédito excedido. " +
$"Límite: ${profile.CreditLimit:N2} | " +
$"Deuda Actual: ${currentDebt:N2} | " +
$"Nuevo Total: ${newTotalDebt:N2}");
}
// 6. PERSISTENCIA TRANSACCIONAL
var orderId = await _orderRepo.CreateOrderAsync(order, orderItems);
// Si pasa, asignamos fecha de vencimiento automática si no vino en el DTO
if (!order.DueDate.HasValue)
// 7. POST-PROCESAMIENTO: ACTIVAR AVISOS
// Si la orden nace pagada, activamos los avisos inmediatamente.
// Si es Cta Cte, TAMBIÉN los activamos (porque se vendió a crédito, el servicio se presta igual).
// Solo si queda en un estado intermedio (ej: 'Review') no lo haríamos.
if (order.PaymentStatus == "Paid" || order.PaymentStatus == "Pending")
{
foreach (var item in orderItems)
{
if (item.RelatedEntityType == "Listing" && item.RelatedEntityId.HasValue)
{
order.DueDate = DateTime.UtcNow.AddDays(profile.PaymentTermsDays);
// Pasamos a 'Pending' (Moderación) o 'Published' directo según regla de negocio.
// Generalmente va a moderación primero.
await _listingRepo.UpdateStatusAsync(item.RelatedEntityId.Value, "Pending");
}
}
}
// 4. Guardar en Base de Datos
var orderId = await _orderRepo.CreateOrderAsync(order, orderItems);
return new OrderResultDto
{
OrderId = orderId,
OrderNumber = order.OrderNumber,
OrderNumber = order.OrderNumber, // El repo actualiza el objeto order con el número generado
TotalAmount = order.TotalAmount,
Status = order.PaymentStatus
};
}
private string GenerateOrderNumber()
{
// Generador simple: ORD-AÑO-RANDOM
// En producción idealmente usaríamos una secuencia de SQL
return $"ORD-{DateTime.Now.Year}-{Guid.NewGuid().ToString().Substring(0, 8).ToUpper()}";
}
}