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

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