Feat ERP 2
This commit is contained in:
49
frontend/admin-panel/package-lock.json
generated
49
frontend/admin-panel/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.24.10",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -3078,6 +3079,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3788,6 +3816,21 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -4337,6 +4380,12 @@
|
|||||||
"typescript": ">=4.8.4"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.24.10",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import ListingExplorer from './pages/Listings/ListingExplorer';
|
|||||||
import AuditTimeline from './pages/Audit/AuditTimeline';
|
import AuditTimeline from './pages/Audit/AuditTimeline';
|
||||||
import ClientManager from './pages/Clients/ClientManager';
|
import ClientManager from './pages/Clients/ClientManager';
|
||||||
import CouponsPage from './pages/Coupons/CouponsPage';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -28,11 +33,16 @@ function App() {
|
|||||||
<Route path="/clients" element={<ClientManager />} />
|
<Route path="/clients" element={<ClientManager />} />
|
||||||
<Route path="/users" element={<UserManager />} />
|
<Route path="/users" element={<UserManager />} />
|
||||||
<Route path="/diagram" element={<DiagramPage />} />
|
<Route path="/diagram" element={<DiagramPage />} />
|
||||||
|
<Route path="/products" element={<ProductManager />} />
|
||||||
<Route path="/pricing" element={<PricingManager />} />
|
<Route path="/pricing" element={<PricingManager />} />
|
||||||
<Route path="/promotions" element={<PromotionsManager />} />
|
<Route path="/promotions" element={<PromotionsManager />} />
|
||||||
<Route path="/coupons" element={<CouponsPage />} />
|
<Route path="/coupons" element={<CouponsPage />} />
|
||||||
<Route path="/reports/categories" element={<SalesByCategory />} />
|
<Route path="/reports/categories" element={<SalesByCategory />} />
|
||||||
<Route path="/audit" element={<AuditTimeline />} />
|
<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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
116
frontend/admin-panel/src/components/Companies/CompanyModal.tsx
Normal file
116
frontend/admin-panel/src/components/Companies/CompanyModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend/admin-panel/src/components/Products/ProductModal.tsx
Normal file
274
frontend/admin-panel/src/components/Products/ProductModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
|
import { Navigate, Outlet, useLocation, Link } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import {
|
import {
|
||||||
LogOut, LayoutDashboard, FolderTree, Users,
|
LogOut, LayoutDashboard, FolderTree, Users, Box,
|
||||||
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag
|
FileText, DollarSign, Eye, History, User as ClientIcon, Search, Tag,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Settings,
|
||||||
|
ShieldAlert
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -39,63 +44,106 @@ export default function ProtectedLayout() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated, role]);
|
}, [isAuthenticated, role]);
|
||||||
|
|
||||||
// Definición de permisos por ruta
|
// Estructura de menú agrupada
|
||||||
const menuItems = [
|
const menuGroups = [
|
||||||
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={20} />, roles: ['Admin', 'Cajero'] },
|
{
|
||||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={20} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
title: "Gestión Diaria",
|
||||||
{ label: 'Explorador', href: '/listings', icon: <Search size={20} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
items: [
|
||||||
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={20} />, roles: ['Admin', 'Cajero'] },
|
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] },
|
||||||
{ label: 'Categorías', href: '/categories', icon: <FolderTree size={20} />, roles: ['Admin'] },
|
{ label: 'Moderación', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
||||||
{ label: 'Usuarios', href: '/users', icon: <Users size={20} />, roles: ['Admin'] },
|
{ label: 'Explorador', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
||||||
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={20} />, roles: ['Admin'] },
|
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={18} />, roles: ['Admin', 'Cajero'] },
|
||||||
{ 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'] },
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
||||||
<aside className="w-64 bg-gray-900 text-white flex flex-col shadow-xl">
|
<aside className="w-64 bg-gray-900 text-white flex flex-col shadow-xl flex-shrink-0">
|
||||||
<div className="p-6 text-xl font-black border-b border-gray-800 tracking-tighter italic text-blue-500">
|
{/* 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>
|
SIG-CM <span className="text-[10px] text-gray-500 not-italic font-medium">ADMIN</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
{/* Navegación con Scroll */}
|
||||||
{menuItems.map((item) => {
|
<nav className="flex-1 overflow-y-auto custom-scrollbar px-3 py-4 space-y-6">
|
||||||
// FILTRO DE SEGURIDAD UI: Solo mostrar si el rol del usuario está permitido
|
{menuGroups.map((group, groupIdx) => {
|
||||||
if (!item.roles.includes(role || '')) return null;
|
// 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 (
|
return (
|
||||||
<Link
|
<div key={groupIdx}>
|
||||||
key={item.href}
|
<h4 className="px-3 mb-2 text-[10px] font-black uppercase tracking-widest text-gray-500">
|
||||||
to={item.href}
|
{group.title}
|
||||||
className={`flex items-center justify-between p-3 rounded-xl transition-all ${location.pathname === item.href
|
</h4>
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
<div className="space-y-1">
|
||||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
{visibleItems.map((item) => (
|
||||||
}`}
|
<Link
|
||||||
>
|
key={item.href}
|
||||||
<div className="flex items-center gap-3">
|
to={item.href}
|
||||||
{item.icon}
|
className={`flex items-center justify-between px-3 py-2.5 rounded-lg transition-all text-sm ${location.pathname === item.href
|
||||||
<span className="font-medium text-sm">{item.label}</span>
|
? '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>
|
</div>
|
||||||
{(item as any).badge > 0 && (
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-gray-800">
|
{/* Footer Fijo */}
|
||||||
<div className="mb-4 px-3 py-2 bg-gray-800/50 rounded-lg">
|
<div className="p-4 border-t border-gray-800 bg-gray-900 flex-shrink-0">
|
||||||
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">Sesión actual</p>
|
<div className="mb-3 px-3 py-2 bg-gray-800/50 rounded-lg flex items-center justify-between">
|
||||||
<p className="text-xs font-bold text-blue-400">{role}</p>
|
<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>
|
</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">
|
<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={18} /> Cerrar Sesión
|
<LogOut size={16} /> Cerrar Sesión
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
135
frontend/admin-panel/src/pages/Companies/CalendarManager.tsx
Normal file
135
frontend/admin-panel/src/pages/Companies/CalendarManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/admin-panel/src/pages/Companies/CompanyManager.tsx
Normal file
127
frontend/admin-panel/src/pages/Companies/CompanyManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
frontend/admin-panel/src/pages/Finance/CreditManager.tsx
Normal file
164
frontend/admin-panel/src/pages/Finance/CreditManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
frontend/admin-panel/src/pages/Products/ProductManager.tsx
Normal file
159
frontend/admin-panel/src/pages/Products/ProductManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
frontend/admin-panel/src/pages/Reports/SettlementReport.tsx
Normal file
78
frontend/admin-panel/src/pages/Reports/SettlementReport.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
frontend/admin-panel/src/services/calendarService.ts
Normal file
17
frontend/admin-panel/src/services/calendarService.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
22
frontend/admin-panel/src/services/companyService.ts
Normal file
22
frontend/admin-panel/src/services/companyService.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
25
frontend/admin-panel/src/services/financeService.ts
Normal file
25
frontend/admin-panel/src/services/financeService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
49
frontend/admin-panel/src/services/productService.ts
Normal file
49
frontend/admin-panel/src/services/productService.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { Product, ProductBundleComponent } from '../types/Product';
|
||||||
|
|
||||||
|
export const productService = {
|
||||||
|
getAll: async (): Promise<Product[]> => {
|
||||||
|
const response = await api.get<Product[]>('/products');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: number): Promise<Product> => {
|
||||||
|
const response = await api.get<Product>(`/products/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (product: Partial<Product>): Promise<Product> => {
|
||||||
|
const response = await api.post<Product>('/products', product);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, product: Partial<Product>): Promise<void> => {
|
||||||
|
await api.put(`/products/${id}`, product);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- GESTIÓN DE COMBOS (BUNDLES) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un producto hijo a un combo padre.
|
||||||
|
*/
|
||||||
|
addComponentToBundle: async (bundleId: number, component: { childProductId: number; quantity: number; fixedAllocationAmount?: number }) => {
|
||||||
|
await api.post(`/products/${bundleId}/components`, component);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un componente de un combo.
|
||||||
|
*/
|
||||||
|
removeComponentFromBundle: async (bundleId: number, childProductId: number) => {
|
||||||
|
// Nota: El backend espera el ID del producto hijo, no el ID de la relación,
|
||||||
|
// según nuestra implementación de 'RemoveComponentFromBundleAsync' en el repo.
|
||||||
|
await api.delete(`/products/${bundleId}/components/${childProductId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la lista de componentes que forman un combo.
|
||||||
|
*/
|
||||||
|
getBundleComponents: async (bundleId: number): Promise<ProductBundleComponent[]> => {
|
||||||
|
const response = await api.get<ProductBundleComponent[]>(`/products/${bundleId}/components`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
6
frontend/admin-panel/src/types/Calendar.ts
Normal file
6
frontend/admin-panel/src/types/Calendar.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface BlackoutDate {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
blackoutDate: string; // ISO Date
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
9
frontend/admin-panel/src/types/Company.ts
Normal file
9
frontend/admin-panel/src/types/Company.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
taxId: string; // CUIT
|
||||||
|
legalAddress?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
externalSystemId?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
17
frontend/admin-panel/src/types/Finance.ts
Normal file
17
frontend/admin-panel/src/types/Finance.ts
Normal 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;
|
||||||
|
}
|
||||||
28
frontend/admin-panel/src/types/Product.ts
Normal file
28
frontend/admin-panel/src/types/Product.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sku?: string;
|
||||||
|
externalId?: string;
|
||||||
|
basePrice: number;
|
||||||
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Campos extendidos para UI (Joins)
|
||||||
|
companyName?: string;
|
||||||
|
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductBundleComponent {
|
||||||
|
id: number;
|
||||||
|
parentProductId: number;
|
||||||
|
childProductId: number;
|
||||||
|
quantity: number;
|
||||||
|
fixedAllocationAmount?: number;
|
||||||
|
|
||||||
|
// Datos del hijo para visualización
|
||||||
|
childProduct?: Product;
|
||||||
|
}
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "src/pages/Products"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import CounterLayout from './layouts/CounterLayout';
|
import CounterLayout from './layouts/CounterLayout';
|
||||||
import FastEntryPage from './pages/FastEntryPage';
|
|
||||||
import CashRegisterPage from './pages/CashRegisterPage';
|
import CashRegisterPage from './pages/CashRegisterPage';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdvancedAnalytics from './pages/AdvancedAnalytics';
|
import AdvancedAnalytics from './pages/AdvancedAnalytics';
|
||||||
@@ -8,6 +7,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import { ToastProvider } from './context/ToastContext';
|
import { ToastProvider } from './context/ToastContext';
|
||||||
import HistoryPage from './pages/HistoryPage';
|
import HistoryPage from './pages/HistoryPage';
|
||||||
import TreasuryPage from './pages/TreasuryPage';
|
import TreasuryPage from './pages/TreasuryPage';
|
||||||
|
import UniversalPosPage from './pages/UniversalPosPage';
|
||||||
|
|
||||||
// Componente simple de protección
|
// Componente simple de protección
|
||||||
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
const PrivateRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -25,7 +25,7 @@ function App() {
|
|||||||
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
|
<Route element={<PrivateRoute><CounterLayout /></PrivateRoute>}>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<AdminDashboard />} />
|
<Route path="/dashboard" element={<AdminDashboard />} />
|
||||||
<Route path="/nuevo-aviso" element={<FastEntryPage />} />
|
<Route path="/pos" element={<UniversalPosPage />} />
|
||||||
<Route path="/caja" element={<CashRegisterPage />} />
|
<Route path="/caja" element={<CashRegisterPage />} />
|
||||||
<Route path="/analitica" element={<AdvancedAnalytics />} />
|
<Route path="/analitica" element={<AdvancedAnalytics />} />
|
||||||
<Route path="/historial" element={<HistoryPage />} />
|
<Route path="/historial" element={<HistoryPage />} />
|
||||||
|
|||||||
299
frontend/counter-panel/src/components/POS/AdEditorModal.tsx
Normal file
299
frontend/counter-panel/src/components/POS/AdEditorModal.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Save, Calendar, Type, AlignLeft, AlignCenter, AlignRight, AlignJustify, Bold, Square as FrameIcon } from 'lucide-react';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import api from '../../services/api';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||||
|
|
||||||
|
interface AdEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (listingId: number, price: number, description: string) => void;
|
||||||
|
clientId: number | null; // El aviso se vinculará a este cliente
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PricingResult {
|
||||||
|
totalPrice: number;
|
||||||
|
wordCount: number;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }: AdEditorModalProps) {
|
||||||
|
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||||
|
const [operations, setOperations] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [calculating, setCalculating] = useState(false);
|
||||||
|
|
||||||
|
// Form State
|
||||||
|
const [categoryId, setCategoryId] = useState('');
|
||||||
|
const [operationId, setOperationId] = useState('');
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const debouncedText = useDebounce(text, 500);
|
||||||
|
const [days, setDays] = useState(3);
|
||||||
|
const [startDate, setStartDate] = useState(new Date(Date.now() + 86400000).toISOString().split('T')[0]); // Mañana default
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const [styles, setStyles] = useState({
|
||||||
|
isBold: false,
|
||||||
|
isFrame: false,
|
||||||
|
fontSize: 'normal',
|
||||||
|
alignment: 'left'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pricing, setPricing] = useState<PricingResult>({ totalPrice: 0, wordCount: 0, details: '' });
|
||||||
|
|
||||||
|
// Carga inicial de datos
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Reset state on open
|
||||||
|
setText('');
|
||||||
|
setDays(3);
|
||||||
|
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
|
||||||
|
setFlatCategories(processCategories(catRes.data));
|
||||||
|
setOperations(opRes.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error cargando configuración", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Calculadora de Precio en Tiempo Real
|
||||||
|
useEffect(() => {
|
||||||
|
if (!categoryId || !text) return;
|
||||||
|
|
||||||
|
const calculate = async () => {
|
||||||
|
setCalculating(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/pricing/calculate', {
|
||||||
|
categoryId: parseInt(categoryId),
|
||||||
|
text: debouncedText,
|
||||||
|
days: days,
|
||||||
|
isBold: styles.isBold,
|
||||||
|
isFrame: styles.isFrame,
|
||||||
|
startDate: startDate
|
||||||
|
});
|
||||||
|
setPricing(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setCalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
calculate();
|
||||||
|
}, [debouncedText, categoryId, days, styles, startDate]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
||||||
|
if (!clientId) return alert("Error interno: Cliente no identificado");
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Creamos el aviso en estado 'Draft' (Borrador) o 'PendingPayment'
|
||||||
|
const payload = {
|
||||||
|
categoryId: parseInt(categoryId),
|
||||||
|
operationId: parseInt(operationId),
|
||||||
|
title: text.substring(0, 30) + '...',
|
||||||
|
description: text,
|
||||||
|
price: 0, // Precio del bien (opcional, no lo pedimos en este form simple)
|
||||||
|
adFee: pricing.totalPrice, // Costo del aviso
|
||||||
|
status: 'Draft', // Importante: Nace como borrador hasta que se paga la Orden
|
||||||
|
origin: 'Mostrador',
|
||||||
|
clientId: clientId,
|
||||||
|
// Datos técnicos de impresión
|
||||||
|
printText: text,
|
||||||
|
printStartDate: startDate,
|
||||||
|
printDaysCount: days,
|
||||||
|
isBold: styles.isBold,
|
||||||
|
isFrame: styles.isFrame,
|
||||||
|
printFontSize: styles.fontSize,
|
||||||
|
printAlignment: styles.alignment
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await api.post('/listings', payload);
|
||||||
|
|
||||||
|
// Devolvemos el ID y el Precio al Carrito
|
||||||
|
onConfirm(res.data.id, pricing.totalPrice, `Aviso: ${text.substring(0, 20)}... (${days} días)`);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Error al guardar el borrador del aviso");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[200] flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white w-full max-w-4xl rounded-[2rem] shadow-2xl flex flex-col max-h-[90vh] overflow-hidden border border-slate-200">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-slate-50 px-8 py-5 border-b border-slate-100 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-black text-slate-800 uppercase tracking-tight">Redacción de Aviso</h3>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Configuración Técnica e Impresión</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl text-slate-400 transition-colors"><X /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-8 overflow-y-auto flex-1 custom-scrollbar space-y-6">
|
||||||
|
|
||||||
|
{/* Clasificación */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Rubro</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm"
|
||||||
|
value={categoryId} onChange={e => setCategoryId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Seleccione Rubro...</option>
|
||||||
|
{flatCategories.map(c => (
|
||||||
|
<option key={c.id} value={c.id} disabled={!c.isSelectable}>
|
||||||
|
{'\u00A0'.repeat(c.level * 2)} {c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Operación</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 bg-slate-50 border-2 border-slate-100 rounded-xl outline-none focus:border-blue-500 font-bold text-sm"
|
||||||
|
value={operationId} onChange={e => setOperationId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Seleccione Operación...</option>
|
||||||
|
{operations.map(op => <option key={op.id} value={op.id}>{op.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor de Texto */}
|
||||||
|
<div className="flex gap-6 min-h-[200px]">
|
||||||
|
<div className="flex-[2] flex flex-col gap-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Texto del Aviso</label>
|
||||||
|
<textarea
|
||||||
|
className={clsx(
|
||||||
|
"w-full h-full p-4 border-2 border-slate-100 rounded-2xl resize-none outline-none focus:border-blue-500 transition-all font-mono text-sm leading-relaxed",
|
||||||
|
styles.isBold && "font-bold",
|
||||||
|
styles.fontSize === 'small' ? 'text-xs' : styles.fontSize === 'large' ? 'text-base' : 'text-sm',
|
||||||
|
styles.alignment === 'center' ? 'text-center' : styles.alignment === 'right' ? 'text-right' : styles.alignment === 'justify' ? 'text-justify' : 'text-left'
|
||||||
|
)}
|
||||||
|
placeholder="Escriba el contenido aquí..."
|
||||||
|
value={text} onChange={e => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel Lateral de Estilos */}
|
||||||
|
<div className="flex-1 bg-slate-50 p-4 rounded-2xl border border-slate-100 space-y-4 h-fit">
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Alineación</label>
|
||||||
|
<div className="flex bg-white p-1 rounded-lg border border-slate-200 justify-between">
|
||||||
|
{['left', 'center', 'right', 'justify'].map(align => (
|
||||||
|
<button
|
||||||
|
key={align}
|
||||||
|
onClick={() => setStyles({ ...styles, alignment: align })}
|
||||||
|
className={clsx("p-2 rounded-md transition-all", styles.alignment === align ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
||||||
|
>
|
||||||
|
{align === 'left' && <AlignLeft size={16} />}
|
||||||
|
{align === 'center' && <AlignCenter size={16} />}
|
||||||
|
{align === 'right' && <AlignRight size={16} />}
|
||||||
|
{align === 'justify' && <AlignJustify size={16} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Estilos</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setStyles({ ...styles, isBold: !styles.isBold })}
|
||||||
|
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isBold ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
||||||
|
>
|
||||||
|
<Bold size={12} /> Negrita
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStyles({ ...styles, isFrame: !styles.isFrame })}
|
||||||
|
className={clsx("p-2 rounded-lg border text-[10px] font-black uppercase flex items-center justify-center gap-1 transition-all", styles.isFrame ? "bg-slate-800 text-white border-slate-800" : "bg-white border-slate-200 text-slate-500")}
|
||||||
|
>
|
||||||
|
<FrameIcon size={12} /> Recuadro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-slate-400 uppercase mb-2 block">Tamaño Fuente</label>
|
||||||
|
<div className="flex bg-white p-1 rounded-lg border border-slate-200 gap-1">
|
||||||
|
{['small', 'normal', 'large'].map(size => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => setStyles({ ...styles, fontSize: size })}
|
||||||
|
className={clsx("flex-1 p-1.5 rounded-md flex justify-center items-end transition-all", styles.fontSize === size ? "bg-blue-100 text-blue-600" : "text-slate-400 hover:text-slate-600")}
|
||||||
|
>
|
||||||
|
<Type size={size === 'small' ? 12 : size === 'normal' ? 16 : 20} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración de Fecha */}
|
||||||
|
<div className="grid grid-cols-3 gap-6 p-4 bg-blue-50/50 rounded-2xl border border-blue-100">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Inicio Publicación</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 text-blue-300" size={16} />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-white border border-blue-200 rounded-xl text-xs font-bold text-slate-700 outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||||
|
value={startDate} onChange={e => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">Cantidad Días</label>
|
||||||
|
<div className="flex items-center bg-white border border-blue-200 rounded-xl overflow-hidden h-[38px]">
|
||||||
|
<button onClick={() => setDays(Math.max(1, days - 1))} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full text-center font-black text-slate-700 text-sm outline-none"
|
||||||
|
value={days} onChange={e => setDays(parseInt(e.target.value) || 1)}
|
||||||
|
/>
|
||||||
|
<button onClick={() => setDays(days + 1)} className="px-3 hover:bg-blue-50 text-blue-400 transition-colors">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 flex flex-col justify-center items-end">
|
||||||
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-0.5">Costo Estimado</span>
|
||||||
|
<div className="text-2xl font-mono font-black text-slate-900">
|
||||||
|
{calculating ? <span className="animate-pulse">...</span> : `$ ${pricing.totalPrice.toLocaleString()}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-3">
|
||||||
|
<button onClick={onClose} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-white transition-all text-xs uppercase tracking-wider">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading || calculating || !text}
|
||||||
|
className="bg-blue-600 text-white px-8 py-3 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save size={16} /> {loading ? 'Procesando...' : 'Confirmar y Agregar al Carrito'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
frontend/counter-panel/src/components/POS/ProductSearch.tsx
Normal file
100
frontend/counter-panel/src/components/POS/ProductSearch.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Search, Package, Layers, FileText } from 'lucide-react';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
import type { Product } from '../../types/Product';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
products: Product[]; // Recibe el catálogo completo cargado en memoria (o podríamos hacerlo server-side search)
|
||||||
|
onSelect: (product: Product) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductSearch({ products, onSelect }: Props) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filtrado local (para catálogos de hasta ~5000 productos es instantáneo en JS)
|
||||||
|
const filtered = products.filter(p =>
|
||||||
|
p.isActive &&
|
||||||
|
(p.name.toLowerCase().includes(debouncedQuery.toLowerCase()) ||
|
||||||
|
p.sku?.toLowerCase().includes(debouncedQuery.toLowerCase()))
|
||||||
|
).slice(0, 10); // Limitar resultados visuales
|
||||||
|
|
||||||
|
// Cerrar al hacer click afuera
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (p: Product) => {
|
||||||
|
onSelect(p);
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (typeCode?: string) => {
|
||||||
|
switch (typeCode) {
|
||||||
|
case 'BUNDLE': return <Layers size={14} className="text-purple-500" />;
|
||||||
|
case 'CLASSIFIED_AD': return <FileText size={14} className="text-blue-500" />;
|
||||||
|
default: return <Package size={14} className="text-slate-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full" ref={wrapperRef}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-500/10 font-bold text-sm transition-all"
|
||||||
|
placeholder="Buscar producto por nombre o SKU (F3)..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && query.length > 1 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl shadow-2xl border border-slate-100 overflow-hidden z-50">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-slate-400 text-xs font-bold uppercase tracking-widest">
|
||||||
|
No se encontraron productos
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map(product => (
|
||||||
|
<button
|
||||||
|
key={product.id}
|
||||||
|
onClick={() => handleSelect(product)}
|
||||||
|
className="w-full text-left p-3 hover:bg-blue-50 border-b border-slate-50 last:border-0 flex items-center justify-between group transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-slate-50 rounded-lg group-hover:bg-white transition-colors">
|
||||||
|
{getIcon(product.typeCode)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm text-slate-800 group-hover:text-blue-700">
|
||||||
|
{product.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider flex gap-2">
|
||||||
|
<span>SKU: {product.sku || 'N/A'}</span>
|
||||||
|
{product.typeCode === 'BUNDLE' && <span className="text-purple-500">• COMBO</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-black text-slate-900 group-hover:text-blue-700">
|
||||||
|
$ {product.basePrice.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { 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 clsx from 'clsx';
|
||||||
|
import { financeService } from '../services/financeService';
|
||||||
|
import type { ClientProfile } from '../types/Finance';
|
||||||
|
|
||||||
// Interfaz de Pago
|
// Interfaz de Pago actualizada
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
amount: number;
|
amount: number;
|
||||||
paymentMethod: string;
|
paymentMethod: string;
|
||||||
@@ -10,14 +12,13 @@ export interface Payment {
|
|||||||
surcharge: number;
|
surcharge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props del Modal de Pagos
|
|
||||||
interface PaymentModalProps {
|
interface PaymentModalProps {
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
onConfirm: (payments: Payment[]) => void;
|
clientId: number | null;
|
||||||
|
onConfirm: (payments: Payment[], isCreditSale: boolean) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Planes de Tarjetas de Crédito con sus recargos
|
|
||||||
const CARD_PLANS = [
|
const CARD_PLANS = [
|
||||||
{ value: 'Ahora12', label: 'Ahora 12', surcharge: 15 },
|
{ value: 'Ahora12', label: 'Ahora 12', surcharge: 15 },
|
||||||
{ value: 'Ahora18', label: 'Ahora 18', surcharge: 20 },
|
{ value: 'Ahora18', label: 'Ahora 18', surcharge: 20 },
|
||||||
@@ -25,32 +26,56 @@ const CARD_PLANS = [
|
|||||||
{ value: 'Credit6', label: '6 Cuotas', surcharge: 12 },
|
{ value: 'Credit6', label: '6 Cuotas', surcharge: 12 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PaymentModal({ totalAmount, onConfirm, onCancel }: PaymentModalProps) {
|
export default function PaymentModal({ totalAmount, clientId, onConfirm, onCancel }: PaymentModalProps) {
|
||||||
// Estado de pagos acumulados
|
|
||||||
const [payments, setPayments] = useState<Payment[]>([]);
|
const [payments, setPayments] = useState<Payment[]>([]);
|
||||||
const [currentMethod, setCurrentMethod] = useState('Cash');
|
const [currentMethod, setCurrentMethod] = useState('Cash');
|
||||||
const [currentAmount, setCurrentAmount] = useState(totalAmount);
|
const [currentAmount, setCurrentAmount] = useState(totalAmount);
|
||||||
const [currentPlan, setCurrentPlan] = useState('');
|
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 totalPaid = payments.reduce((sum, p) => sum + p.amount, 0);
|
||||||
const pendingAmount = totalAmount - totalPaid;
|
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(() => {
|
useEffect(() => {
|
||||||
setCurrentAmount(Math.max(0, pendingAmount));
|
setCurrentAmount(Math.max(0, pendingAmount));
|
||||||
}, [pendingAmount]);
|
}, [pendingAmount]);
|
||||||
|
|
||||||
// Agregar un nuevo pago a la lista
|
|
||||||
const addPayment = () => {
|
const addPayment = () => {
|
||||||
if (currentAmount <= 0 || currentAmount > pendingAmount) {
|
if (currentAmount <= 0 || currentAmount > pendingAmount + 0.01) { // Pequeña tolerancia float
|
||||||
alert('❌ Monto inválido');
|
alert('❌ Monto inválido');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentMethod === 'Credit' && !currentPlan) {
|
// Validaciones específicas
|
||||||
alert('❌ Seleccione un plan de cuotas');
|
if (currentMethod === 'Credit' && !currentPlan) return alert('❌ Seleccione un plan de cuotas');
|
||||||
return;
|
|
||||||
|
// 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);
|
const planInfo = CARD_PLANS.find(p => p.value === currentPlan);
|
||||||
@@ -64,87 +89,116 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
|||||||
};
|
};
|
||||||
|
|
||||||
setPayments([...payments, newPayment]);
|
setPayments([...payments, newPayment]);
|
||||||
|
|
||||||
|
// Resetear form
|
||||||
setCurrentMethod('Cash');
|
setCurrentMethod('Cash');
|
||||||
setCurrentPlan('');
|
setCurrentPlan('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Eliminar un pago de la lista
|
|
||||||
const removePayment = (index: number) => {
|
const removePayment = (index: number) => {
|
||||||
setPayments(payments.filter((_, i) => i !== index));
|
setPayments(payments.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Confirmar todos los pagos
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (pendingAmount > 0) {
|
if (pendingAmount > 1) { // Tolerancia $1
|
||||||
alert('❌ Aún falta completar el pago');
|
alert('❌ Aún falta completar el pago');
|
||||||
return;
|
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);
|
const totalWithSurcharges = payments.reduce((sum, p) => sum + p.amount + p.surcharge, 0);
|
||||||
|
|
||||||
return (
|
// Helper para mostrar info de crédito
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
const renderCreditInfo = () => {
|
||||||
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
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>;
|
||||||
{/* Encabezado */}
|
if (profileError) return <div className="text-xs text-slate-400 italic">Cta. Cte. no disponible ({profileError})</div>;
|
||||||
<div className="sticky top-0 bg-gradient-to-r from-blue-600 to-indigo-600 text-white p-6 rounded-t-3xl">
|
if (!clientProfile) return <div className="text-xs text-slate-400 italic">Consumidor Final (Solo Contado)</div>;
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
const available = clientProfile.creditLimit - clientProfile.currentDebt;
|
||||||
<h2 className="text-2xl font-black">Procesar Pago</h2>
|
const isBlocked = clientProfile.isCreditBlocked;
|
||||||
<p className="text-sm text-blue-100 mt-1">Configure los medios de pago</p>
|
|
||||||
</div>
|
return (
|
||||||
<button onClick={onCancel} className="hover:bg-white/20 p-2 rounded-full transition">
|
<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")}>
|
||||||
<X size={24} />
|
<div className="font-bold uppercase mb-1">{isBlocked ? "CRÉDITO BLOQUEADO" : "LÍNEA DE CRÉDITO"}</div>
|
||||||
</button>
|
{!isBlocked && (
|
||||||
|
<div className="flex justify-between font-mono">
|
||||||
|
<span>Disponible:</span>
|
||||||
|
<span className="font-black">${available.toLocaleString()}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-8 space-y-8">
|
||||||
{/* Resumen de Importes */}
|
{/* Resumen de Importes */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-200">
|
<div className="bg-white p-4 rounded-2xl border-2 border-slate-100 shadow-sm">
|
||||||
<div className="text-xs text-blue-600 font-bold uppercase mb-1">Total Original</div>
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Total a Pagar</div>
|
||||||
<div className="text-2xl font-black text-blue-900">${totalAmount.toLocaleString()}</div>
|
<div className="text-2xl font-mono font-black text-slate-800">${totalAmount.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 p-4 rounded-xl border border-green-200">
|
<div className="bg-emerald-50 p-4 rounded-2xl border-2 border-emerald-100 shadow-sm">
|
||||||
<div className="text-xs text-green-600 font-bold uppercase mb-1">Pagado</div>
|
<div className="text-[9px] font-black text-emerald-600 uppercase tracking-widest mb-1">Cubierto</div>
|
||||||
<div className="text-2xl font-black text-green-900">${totalPaid.toLocaleString()}</div>
|
<div className="text-2xl font-mono font-black text-emerald-700">${totalPaid.toLocaleString()}</div>
|
||||||
</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("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-xs font-bold uppercase mb-1", pendingAmount > 0 ? "text-orange-600" : "text-gray-500")}>Pendiente</div>
|
<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-black", pendingAmount > 0 ? "text-orange-900" : "text-gray-400")}>${pendingAmount.toLocaleString()}</div>
|
<div className={clsx("text-2xl font-mono font-black", pendingAmount > 1 ? "text-rose-700" : "text-slate-300")}>${pendingAmount.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lista de Pagos Agregados */}
|
{/* Lista de Pagos Agregados */}
|
||||||
{payments.length > 0 && (
|
{payments.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Pagos Agregados</h3>
|
<h3 className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Desglose de Pagos</h3>
|
||||||
{payments.map((payment, index) => (
|
{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 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-3">
|
<div className="flex items-center gap-4">
|
||||||
{payment.paymentMethod === 'Cash' && <Banknote className="text-green-600" size={20} />}
|
<div className="p-2 bg-slate-50 rounded-lg text-slate-500">
|
||||||
{payment.paymentMethod === 'Debit' && <CreditCard className="text-blue-600" size={20} />}
|
{payment.paymentMethod === 'Cash' && <Banknote size={18} />}
|
||||||
{payment.paymentMethod === 'Credit' && <CreditCard className="text-purple-600" size={20} />}
|
{payment.paymentMethod === 'Debit' && <CreditCard size={18} />}
|
||||||
{payment.paymentMethod === 'Transfer' && <ArrowRightLeft className="text-indigo-600" size={20} />}
|
{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>
|
||||||
<div className="font-bold text-sm">
|
<div className="font-bold text-xs text-slate-700 uppercase tracking-tight">
|
||||||
{payment.paymentMethod === 'Cash' && 'Efectivo'}
|
{payment.paymentMethod === 'Cash' && 'Efectivo'}
|
||||||
{payment.paymentMethod === 'Debit' && 'Débito'}
|
{payment.paymentMethod === 'Debit' && 'Débito'}
|
||||||
{payment.paymentMethod === 'Credit' && payment.cardPlan}
|
{payment.paymentMethod === 'Credit' && `Crédito (${payment.cardPlan})`}
|
||||||
{payment.paymentMethod === 'Transfer' && 'Transferencia'}
|
{payment.paymentMethod === 'Transfer' && 'Transferencia'}
|
||||||
|
{payment.paymentMethod === 'CurrentAccount' && 'Cuenta Corriente'}
|
||||||
</div>
|
</div>
|
||||||
{payment.surcharge > 0 && (
|
{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>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<span className="font-black text-lg">${payment.amount.toLocaleString()}</span>
|
<span className="font-mono font-black text-slate-800">${payment.amount.toLocaleString()}</span>
|
||||||
<button onClick={() => removePayment(index)} className="text-red-500 hover:bg-red-50 p-1 rounded">
|
<button onClick={() => removePayment(index)} className="text-slate-300 hover:text-rose-500 transition-colors">
|
||||||
<X size={18} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,42 +207,49 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Formulario para Agregar Pago */}
|
{/* Formulario para Agregar Pago */}
|
||||||
{pendingAmount > 0 && (
|
{pendingAmount > 1 && (
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-xl p-4 space-y-4">
|
<div className="bg-slate-50 border-2 border-slate-100 rounded-[1.5rem] p-6 space-y-6">
|
||||||
<h3 className="text-sm font-bold text-gray-600 uppercase">Agregar Pago</h3>
|
<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 */}
|
{/* Selector de Método */}
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<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: 'Debit', label: 'Débito', icon: CreditCard, color: 'blue' },
|
||||||
{ value: 'Credit', label: 'Crédito', icon: CreditCard, color: 'purple' },
|
{ 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) => (
|
].map((method) => (
|
||||||
<button
|
<button
|
||||||
key={method.value}
|
key={method.value}
|
||||||
onClick={() => setCurrentMethod(method.value)}
|
onClick={() => !method.disabled && setCurrentMethod(method.value)}
|
||||||
|
disabled={method.disabled}
|
||||||
className={clsx(
|
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
|
currentMethod === method.value
|
||||||
? `border-${method.color}-600 bg-${method.color}-50`
|
? `border-${method.color}-500 bg-white text-${method.color}-600 shadow-md`
|
||||||
: "border-gray-200 hover:border-gray-300"
|
: 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'} />
|
<method.icon size={20} />
|
||||||
<span className="text-xs font-bold">{method.label}</span>
|
<span className="text-[9px] font-black uppercase">{method.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selector de Plan de Cuotas (solo para Crédito) */}
|
{/* Selector de Plan (Crédito) */}
|
||||||
{currentMethod === 'Credit' && (
|
{currentMethod === 'Credit' && (
|
||||||
<div>
|
<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
|
<select
|
||||||
value={currentPlan}
|
value={currentPlan}
|
||||||
onChange={(e) => setCurrentPlan(e.target.value)}
|
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>
|
<option value="">-- Seleccionar Plan --</option>
|
||||||
{CARD_PLANS.map(plan => (
|
{CARD_PLANS.map(plan => (
|
||||||
@@ -202,62 +263,60 @@ export default function PaymentModal({ totalAmount, onConfirm, onCancel }: Payme
|
|||||||
|
|
||||||
{/* Input de Monto */}
|
{/* Input de Monto */}
|
||||||
<div>
|
<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">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentAmount}
|
value={currentAmount}
|
||||||
onChange={(e) => setCurrentAmount(parseFloat(e.target.value) || 0)}
|
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}
|
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>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resumen Final con Recargos */}
|
{/* Resumen Final con Recargos */}
|
||||||
{totalWithSurcharges !== totalAmount && (
|
{totalWithSurcharges !== totalAmount && (
|
||||||
<div className="bg-orange-50 border border-orange-200 p-4 rounded-lg">
|
<div className="bg-orange-50 border border-orange-100 p-4 rounded-xl flex justify-between items-center text-orange-800">
|
||||||
<div className="flex justify-between items-center">
|
<span className="text-xs font-black uppercase tracking-widest flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-orange-700">Total con Recargos</span>
|
<AlertCircle size={16} /> Total Final (Con Recargos)
|
||||||
<span className="text-2xl font-black text-orange-900">${totalWithSurcharges.toLocaleString()}</span>
|
</span>
|
||||||
</div>
|
<span className="text-xl font-mono font-black">${totalWithSurcharges.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botones de Acción */}
|
{/* 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
|
<button
|
||||||
onClick={onCancel}
|
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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={pendingAmount > 0}
|
disabled={pendingAmount > 1}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex-1 py-4 rounded-xl font-black uppercase transition",
|
"flex-[2] py-4 rounded-xl font-black text-xs uppercase tracking-widest transition-all shadow-lg",
|
||||||
pendingAmount > 0
|
pendingAmount > 1
|
||||||
? "bg-gray-300 text-gray-500 cursor-not-allowed"
|
? "bg-slate-200 text-slate-400 cursor-not-allowed shadow-none"
|
||||||
: "bg-green-600 hover:bg-green-700 text-white"
|
: "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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export default function CounterLayout() {
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'F1': '/dashboard',
|
'F1': '/dashboard',
|
||||||
'F2': '/nuevo-aviso',
|
'F2': '/pos',
|
||||||
'F4': '/caja',
|
'F4': '/caja',
|
||||||
'F6': '/analitica',
|
'F6': '/analitica',
|
||||||
'F8': '/historial',
|
'F8': '/historial',
|
||||||
@@ -70,7 +70,7 @@ export default function CounterLayout() {
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/dashboard', label: 'Panel Principal', icon: LayoutDashboard, shortcut: 'F1' },
|
{ 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: '/caja', label: 'Caja Diaria', icon: Banknote, shortcut: 'F4' },
|
||||||
{ path: '/historial', label: 'Consultas', icon: ClipboardList, shortcut: 'F8' },
|
{ path: '/historial', label: 'Consultas', icon: ClipboardList, shortcut: 'F8' },
|
||||||
{ path: '/analitica', label: 'Analítica', icon: TrendingUp, shortcut: 'F6' },
|
{ path: '/analitica', label: 'Analítica', icon: TrendingUp, shortcut: 'F6' },
|
||||||
|
|||||||
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
302
frontend/counter-panel/src/pages/UniversalPosPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useCartStore } from '../store/cartStore';
|
||||||
|
import { productService } from '../services/productService';
|
||||||
|
import type { Product } from '../types/Product';
|
||||||
|
import ProductSearch from '../components/POS/ProductSearch';
|
||||||
|
import { Trash2, ShoppingCart, CreditCard, User, Box, Layers } from 'lucide-react';
|
||||||
|
import { useToast } from '../context/use-toast';
|
||||||
|
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||||
|
import { orderService } from '../services/orderService';
|
||||||
|
import type { CreateOrderRequest } from '../types/Order';
|
||||||
|
import AdEditorModal from '../components/POS/AdEditorModal';
|
||||||
|
// Importamos el componente de búsqueda de clientes para el modal (Asumiremos que existe o usamos un simple prompt por ahora para no extender demasiado, idealmente ClientSearchModal)
|
||||||
|
// import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||||
|
|
||||||
|
export default function UniversalPosPage() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const { items, addItem, removeItem, clearCart, getTotal, clientId, clientName, setClient, sellerId, setSeller } = useCartStore();
|
||||||
|
|
||||||
|
const [catalog, setCatalog] = useState<Product[]>([]);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [showPayment, setShowPayment] = useState(false);
|
||||||
|
|
||||||
|
// Estados de Modales
|
||||||
|
const [showAdEditor, setShowAdEditor] = useState(false);
|
||||||
|
const [selectedAdProduct, setSelectedAdProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
|
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
|
||||||
|
const [addingProduct, setAddingProduct] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
productService.getAll().then(setCatalog).catch(console.error);
|
||||||
|
const userStr = localStorage.getItem('user');
|
||||||
|
if (userStr) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
if (user.id) setSeller(user.id);
|
||||||
|
} catch { /* ... */ }
|
||||||
|
}
|
||||||
|
}, [setSeller]);
|
||||||
|
|
||||||
|
// Manejador de Teclado Global del POS
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// F10: Cobrar
|
||||||
|
if (e.key === 'F10') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCheckout();
|
||||||
|
}
|
||||||
|
// F7: Cambiar Cliente (Antes F9)
|
||||||
|
if (e.key === 'F7') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleChangeClient();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [items, clientId]); // Dependencias para que handleCheckout tenga el estado fresco
|
||||||
|
|
||||||
|
const handleChangeClient = () => {
|
||||||
|
// Aquí abriríamos el ClientSearchModal.
|
||||||
|
// Para no bloquear, simulamos un cambio rápido o un prompt simple si no hay modal aún.
|
||||||
|
// En producción: setShowClientModal(true);
|
||||||
|
const id = prompt("Ingrese ID de Cliente (Simulación F7):", "1003");
|
||||||
|
if (id) {
|
||||||
|
// Buscar nombre real en API...
|
||||||
|
setClient(parseInt(id), "Cliente #" + id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProductSelect = async (product: Product) => {
|
||||||
|
setAddingProduct(true);
|
||||||
|
try {
|
||||||
|
// 1. AVISOS CLASIFICADOS
|
||||||
|
if (product.typeCode === 'CLASSIFIED_AD') {
|
||||||
|
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
||||||
|
setSelectedAdProduct(product);
|
||||||
|
setShowAdEditor(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. COMBOS (BUNDLES) - Lógica de Visualización
|
||||||
|
if (product.typeCode === 'BUNDLE') {
|
||||||
|
// Traemos los componentes para mostrarlos en el ticket
|
||||||
|
const components = await productService.getBundleComponents(product.id);
|
||||||
|
const subItemsNames = components.map(c =>
|
||||||
|
`${c.quantity}x ${c.childProduct?.name || 'Item'}`
|
||||||
|
);
|
||||||
|
|
||||||
|
addItem(product, 1, { subItems: subItemsNames });
|
||||||
|
showToast(`Combo agregado con ${components.length} ítems`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. PRODUCTO ESTÁNDAR
|
||||||
|
addItem(product, 1);
|
||||||
|
showToast(`${product.name} agregado`, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
showToast("Error al agregar producto", "error");
|
||||||
|
} finally {
|
||||||
|
setAddingProduct(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdConfirmed = (listingId: number, price: number, description: string) => {
|
||||||
|
if (selectedAdProduct) {
|
||||||
|
addItem(
|
||||||
|
{ ...selectedAdProduct, basePrice: price },
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
relatedEntity: { id: listingId, type: 'Listing', extraInfo: description }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
showToast('Aviso agregado al carrito', 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckout = () => {
|
||||||
|
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
||||||
|
if (!clientId) setClient(1005, "Consumidor Final");
|
||||||
|
setShowPayment(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const isDirectPayment = !isCreditSale;
|
||||||
|
const payload: CreateOrderRequest = {
|
||||||
|
clientId: clientId || 1005,
|
||||||
|
sellerId: sellerId || 2,
|
||||||
|
isDirectPayment: isDirectPayment,
|
||||||
|
notes: "Venta de Mostrador (Universal POS)",
|
||||||
|
items: items.map(i => ({
|
||||||
|
productId: i.productId,
|
||||||
|
quantity: i.quantity,
|
||||||
|
relatedEntityId: i.relatedEntityId,
|
||||||
|
relatedEntityType: i.relatedEntityType
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await orderService.createOrder(payload);
|
||||||
|
showToast(`Orden ${result.orderNumber} generada con éxito`, 'success');
|
||||||
|
clearCart();
|
||||||
|
setShowPayment(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error);
|
||||||
|
const msg = error.response?.data?.message || error.message || "Error al procesar la venta";
|
||||||
|
showToast(msg, "error");
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`h-full flex gap-6 p-6 ${addingProduct ? 'cursor-wait opacity-80' : ''}`}>
|
||||||
|
{/* SECCIÓN IZQUIERDA */}
|
||||||
|
<div className="flex-[2] flex flex-col gap-6 min-w-0">
|
||||||
|
<div className="bg-white p-6 rounded-[2rem] shadow-sm border border-slate-200">
|
||||||
|
<h2 className="text-xl font-black text-slate-800 uppercase tracking-tight mb-4 flex items-center gap-2">
|
||||||
|
<Box className="text-blue-600" /> Nueva Venta
|
||||||
|
</h2>
|
||||||
|
<ProductSearch products={catalog} onSelect={handleProductSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 p-6 overflow-y-auto custom-scrollbar">
|
||||||
|
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Accesos Rápidos</h3>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{catalog.filter(p => p.typeCode === 'PHYSICAL' || p.typeCode === 'BUNDLE').slice(0, 9).map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleProductSelect(p)}
|
||||||
|
className="bg-white p-4 rounded-xl shadow-sm border border-slate-200 hover:border-blue-400 hover:shadow-md transition-all text-left group flex flex-col justify-between h-24 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{p.typeCode === 'BUNDLE' && (
|
||||||
|
<div className="absolute top-0 right-0 bg-purple-100 text-purple-600 p-1 rounded-bl-lg">
|
||||||
|
<Layers size={12} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs font-bold text-slate-700 group-hover:text-blue-700 line-clamp-2 leading-tight pr-4">
|
||||||
|
{p.name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-black text-slate-400 mt-1 uppercase">{p.typeCode === 'BUNDLE' ? 'Combo' : 'Producto'}</div>
|
||||||
|
<div className="text-sm font-black text-slate-900">$ {p.basePrice.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SECCIÓN DERECHA: CARRITO */}
|
||||||
|
<div className="flex-1 bg-white rounded-[2rem] shadow-xl border border-slate-200 flex flex-col overflow-hidden min-w-[340px]">
|
||||||
|
{/* Header Carrito */}
|
||||||
|
<div className="p-6 bg-slate-900 text-white border-b border-slate-800 shrink-0">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-blue-400">Orden Actual</span>
|
||||||
|
<ShoppingCart size={18} className="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-mono font-black tracking-tight truncate">
|
||||||
|
$ {getTotal().toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lista de Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-slate-300 gap-2">
|
||||||
|
<ShoppingCart size={48} className="opacity-20" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest">Carrito Vacío</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<div key={item.tempId} className="flex flex-col bg-slate-50 rounded-xl border border-slate-100 group hover:border-blue-200 transition-colors overflow-hidden">
|
||||||
|
{/* Cabecera del Item */}
|
||||||
|
<div className="flex justify-between items-center p-3">
|
||||||
|
<div className="flex-1 min-w-0 pr-2">
|
||||||
|
<div className="text-xs font-bold text-slate-800 truncate" title={item.productName}>
|
||||||
|
{item.productName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-500">
|
||||||
|
{item.quantity} x ${item.unitPrice.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<span className="font-mono font-black text-sm text-slate-900">${item.subTotal.toLocaleString()}</span>
|
||||||
|
<button onClick={() => removeItem(item.tempId)} className="text-rose-300 hover:text-rose-500 transition-colors p-1 hover:bg-rose-50 rounded">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VISUALIZACIÓN DE COMPONENTES DEL COMBO */}
|
||||||
|
{item.subItems && item.subItems.length > 0 && (
|
||||||
|
<div className="bg-purple-50/50 px-3 py-2 border-t border-slate-100">
|
||||||
|
<div className="text-[9px] font-black text-purple-400 uppercase tracking-widest mb-1 flex items-center gap-1">
|
||||||
|
<Layers size={10} /> Incluye:
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{item.subItems.map((sub, idx) => (
|
||||||
|
<li key={idx} className="text-[10px] text-slate-600 pl-3 relative flex items-center gap-1">
|
||||||
|
<div className="w-1 h-1 bg-purple-300 rounded-full"></div>
|
||||||
|
{sub}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cliente y Acciones */}
|
||||||
|
<div className="p-6 border-t border-slate-100 bg-slate-50 space-y-4 shrink-0">
|
||||||
|
<div
|
||||||
|
onClick={handleChangeClient}
|
||||||
|
className="flex items-center justify-between p-3 bg-white rounded-xl border border-slate-200 cursor-pointer hover:border-blue-300 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg group-hover:bg-blue-100 transition-colors"><User size={16} /></div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Cliente</div>
|
||||||
|
<div className="text-xs font-bold text-slate-800 truncate">{clientName || "Consumidor Final"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-blue-600 uppercase whitespace-nowrap">F7 Cambiar</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCheckout}
|
||||||
|
disabled={items.length === 0 || isProcessing}
|
||||||
|
className="w-full py-4 bg-emerald-600 text-white rounded-xl font-black uppercase text-xs tracking-widest shadow-lg shadow-emerald-200 hover:bg-emerald-700 transition-all flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
|
||||||
|
>
|
||||||
|
<CreditCard size={16} /> {isProcessing ? 'Procesando...' : 'Cobrar Orden (F10)'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- MODALES --- */}
|
||||||
|
|
||||||
|
{showPayment && (
|
||||||
|
<PaymentModal
|
||||||
|
totalAmount={getTotal()}
|
||||||
|
clientId={clientId}
|
||||||
|
onConfirm={finalizeOrder}
|
||||||
|
onCancel={() => setShowPayment(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAdEditor && (
|
||||||
|
<AdEditorModal
|
||||||
|
isOpen={showAdEditor}
|
||||||
|
onClose={() => setShowAdEditor(false)}
|
||||||
|
onConfirm={handleAdConfirmed}
|
||||||
|
clientId={clientId || 1005}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/counter-panel/src/services/companyService.ts
Normal file
12
frontend/counter-panel/src/services/companyService.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { Company } from '../types/Company';
|
||||||
|
|
||||||
|
export const companyService = {
|
||||||
|
/**
|
||||||
|
* Obtiene el listado de empresas activas para selectores.
|
||||||
|
*/
|
||||||
|
getAll: async (): Promise<Company[]> => {
|
||||||
|
const response = await api.get<Company[]>('/products/companies');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
12
frontend/counter-panel/src/services/financeService.ts
Normal file
12
frontend/counter-panel/src/services/financeService.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { ClientProfile } from '../types/Finance';
|
||||||
|
|
||||||
|
export const financeService = {
|
||||||
|
/**
|
||||||
|
* Obtiene el perfil financiero y la deuda actual de un cliente.
|
||||||
|
*/
|
||||||
|
getClientStatus: async (clientId: number): Promise<ClientProfile> => {
|
||||||
|
const response = await api.get<ClientProfile>(`/finance/client/${clientId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
32
frontend/counter-panel/src/services/orderService.ts
Normal file
32
frontend/counter-panel/src/services/orderService.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { CreateOrderRequest, OrderResult } from '../types/Order';
|
||||||
|
|
||||||
|
export const orderService = {
|
||||||
|
/**
|
||||||
|
* Crea una orden de venta completa (Contado o Cta Cte)
|
||||||
|
*/
|
||||||
|
createOrder: async (request: CreateOrderRequest): Promise<OrderResult> => {
|
||||||
|
// Validaciones de seguridad antes de enviar
|
||||||
|
if (request.items.length === 0) throw new Error("La orden no puede estar vacía.");
|
||||||
|
if (request.clientId <= 0) throw new Error("Debe seleccionar un cliente.");
|
||||||
|
|
||||||
|
const response = await api.post<OrderResult>('/orders', request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el historial de órdenes de un cliente específico
|
||||||
|
*/
|
||||||
|
getByClient: async (clientId: number) => {
|
||||||
|
const response = await api.get(`/orders/client/${clientId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el detalle completo de una orden por ID
|
||||||
|
*/
|
||||||
|
getById: async (id: number) => {
|
||||||
|
const response = await api.get(`/orders/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
49
frontend/counter-panel/src/services/productService.ts
Normal file
49
frontend/counter-panel/src/services/productService.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import api from './api';
|
||||||
|
import type { Product, ProductBundleComponent } from '../types/Product';
|
||||||
|
|
||||||
|
export const productService = {
|
||||||
|
getAll: async (): Promise<Product[]> => {
|
||||||
|
const response = await api.get<Product[]>('/products');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: number): Promise<Product> => {
|
||||||
|
const response = await api.get<Product>(`/products/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (product: Partial<Product>): Promise<Product> => {
|
||||||
|
const response = await api.post<Product>('/products', product);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: number, product: Partial<Product>): Promise<void> => {
|
||||||
|
await api.put(`/products/${id}`, product);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- GESTIÓN DE COMBOS (BUNDLES) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un producto hijo a un combo padre.
|
||||||
|
*/
|
||||||
|
addComponentToBundle: async (bundleId: number, component: { childProductId: number; quantity: number; fixedAllocationAmount?: number }) => {
|
||||||
|
await api.post(`/products/${bundleId}/components`, component);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un componente de un combo.
|
||||||
|
*/
|
||||||
|
removeComponentFromBundle: async (bundleId: number, childProductId: number) => {
|
||||||
|
// Nota: El backend espera el ID del producto hijo, no el ID de la relación,
|
||||||
|
// según nuestra implementación de 'RemoveComponentFromBundleAsync' en el repo.
|
||||||
|
await api.delete(`/products/${bundleId}/components/${childProductId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la lista de componentes que forman un combo.
|
||||||
|
*/
|
||||||
|
getBundleComponents: async (bundleId: number): Promise<ProductBundleComponent[]> => {
|
||||||
|
const response = await api.get<ProductBundleComponent[]>(`/products/${bundleId}/components`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
84
frontend/counter-panel/src/store/cartStore.ts
Normal file
84
frontend/counter-panel/src/store/cartStore.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { OrderItemDto } from '../types/Order';
|
||||||
|
import type { Product } from '../types/Product';
|
||||||
|
|
||||||
|
interface CartItem extends OrderItemDto {
|
||||||
|
tempId: string;
|
||||||
|
productName: string;
|
||||||
|
unitPrice: number;
|
||||||
|
subTotal: number;
|
||||||
|
// Lista de nombres de componentes para mostrar en el ticket/pantalla
|
||||||
|
subItems?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartState {
|
||||||
|
items: CartItem[];
|
||||||
|
clientId: number | null;
|
||||||
|
clientName: string | null;
|
||||||
|
sellerId: number | null;
|
||||||
|
|
||||||
|
addItem: (
|
||||||
|
product: Product,
|
||||||
|
quantity: number,
|
||||||
|
options?: {
|
||||||
|
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
||||||
|
subItems?: string[]
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
removeItem: (tempId: string) => void;
|
||||||
|
setClient: (id: number, name: string) => void;
|
||||||
|
setSeller: (id: number) => void;
|
||||||
|
clearCart: () => void;
|
||||||
|
getTotal: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = create<CartState>((set, get) => ({
|
||||||
|
items: [],
|
||||||
|
clientId: null,
|
||||||
|
clientName: null,
|
||||||
|
sellerId: null,
|
||||||
|
|
||||||
|
addItem: (product, quantity, options) => {
|
||||||
|
const currentItems = get().items;
|
||||||
|
const { relatedEntity, subItems } = options || {};
|
||||||
|
|
||||||
|
// Si tiene entidad relacionada (Aviso) o SubItems (Combo), no agrupamos
|
||||||
|
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0);
|
||||||
|
|
||||||
|
const existingIndex = !isComplexItem
|
||||||
|
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updatedItems = [...currentItems];
|
||||||
|
updatedItems[existingIndex].quantity += quantity;
|
||||||
|
updatedItems[existingIndex].subTotal = updatedItems[existingIndex].quantity * updatedItems[existingIndex].unitPrice;
|
||||||
|
set({ items: updatedItems });
|
||||||
|
} else {
|
||||||
|
const newItem: CartItem = {
|
||||||
|
tempId: Math.random().toString(36).substring(7),
|
||||||
|
productId: product.id,
|
||||||
|
quantity: quantity,
|
||||||
|
productName: relatedEntity?.extraInfo
|
||||||
|
? `${product.name} (${relatedEntity.extraInfo})`
|
||||||
|
: product.name,
|
||||||
|
unitPrice: product.basePrice,
|
||||||
|
subTotal: product.basePrice * quantity,
|
||||||
|
relatedEntityId: relatedEntity?.id,
|
||||||
|
relatedEntityType: relatedEntity?.type,
|
||||||
|
subItems: subItems // Guardamos la lista visual
|
||||||
|
};
|
||||||
|
set({ items: [...currentItems, newItem] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeItem: (tempId) => {
|
||||||
|
set({ items: get().items.filter(i => i.tempId !== tempId) });
|
||||||
|
},
|
||||||
|
|
||||||
|
setClient: (id, name) => set({ clientId: id, clientName: name }),
|
||||||
|
setSeller: (id) => set({ sellerId: id }),
|
||||||
|
clearCart: () => set({ items: [], clientId: null, clientName: null }),
|
||||||
|
getTotal: () => get().items.reduce((sum, item) => sum + item.subTotal, 0)
|
||||||
|
}));
|
||||||
9
frontend/counter-panel/src/types/Company.ts
Normal file
9
frontend/counter-panel/src/types/Company.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Company {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
taxId: string; // CUIT
|
||||||
|
legalAddress?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
externalSystemId?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
11
frontend/counter-panel/src/types/Finance.ts
Normal file
11
frontend/counter-panel/src/types/Finance.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ClientProfile {
|
||||||
|
userId: number;
|
||||||
|
creditLimit: number;
|
||||||
|
paymentTermsDays: number;
|
||||||
|
isCreditBlocked: boolean;
|
||||||
|
blockReason?: string;
|
||||||
|
lastCreditCheckAt: string;
|
||||||
|
|
||||||
|
// Propiedad calculada en backend
|
||||||
|
currentDebt: number;
|
||||||
|
}
|
||||||
28
frontend/counter-panel/src/types/Order.ts
Normal file
28
frontend/counter-panel/src/types/Order.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface OrderItemDto {
|
||||||
|
productId: number;
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
// Para trazabilidad en UI (no se envía al backend, pero sirve para mostrar)
|
||||||
|
productName?: string;
|
||||||
|
unitPrice?: number;
|
||||||
|
|
||||||
|
// Vinculación Polimórfica (para Avisos/Servicios complejos)
|
||||||
|
relatedEntityId?: number;
|
||||||
|
relatedEntityType?: string; // 'Listing', etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrderRequest {
|
||||||
|
clientId: number;
|
||||||
|
sellerId: number;
|
||||||
|
dueDate?: string; // ISO Date para Cta Cte
|
||||||
|
notes?: string;
|
||||||
|
isDirectPayment: boolean; // true = Contado, false = Cta Cte
|
||||||
|
items: OrderItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderResult {
|
||||||
|
orderId: number;
|
||||||
|
orderNumber: string;
|
||||||
|
totalAmount: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
28
frontend/counter-panel/src/types/Product.ts
Normal file
28
frontend/counter-panel/src/types/Product.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
companyId: number;
|
||||||
|
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sku?: string;
|
||||||
|
externalId?: string;
|
||||||
|
basePrice: number;
|
||||||
|
taxRate: number;
|
||||||
|
currency: string;
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Campos extendidos para UI (Joins)
|
||||||
|
companyName?: string;
|
||||||
|
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductBundleComponent {
|
||||||
|
id: number;
|
||||||
|
parentProductId: number;
|
||||||
|
childProductId: number;
|
||||||
|
quantity: number;
|
||||||
|
fixedAllocationAmount?: number;
|
||||||
|
|
||||||
|
// Datos del hijo para visualización
|
||||||
|
childProduct?: Product;
|
||||||
|
}
|
||||||
7
frontend/counter-panel/src/types/Seller.ts
Normal file
7
frontend/counter-panel/src/types/Seller.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface SellerProfile {
|
||||||
|
userId: number;
|
||||||
|
sellerCode?: string;
|
||||||
|
baseCommissionPercentage: number;
|
||||||
|
isActive: boolean;
|
||||||
|
username?: string; // Para mostrar el nombre
|
||||||
|
}
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "../admin-panel/src/pages/Products"]
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/SIGCM.API/Controllers/CalendarController.cs
Normal file
40
src/SIGCM.API/Controllers/CalendarController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/SIGCM.API/Controllers/CompaniesController.cs
Normal file
56
src/SIGCM.API/Controllers/CompaniesController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -99,4 +99,23 @@ public class ProductsController : ControllerBase
|
|||||||
await _repository.RemoveComponentFromBundleAsync(bundleId, childId);
|
await _repository.RemoveComponentFromBundleAsync(bundleId, childId);
|
||||||
return NoContent();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Text;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SIGCM.Domain.Interfaces;
|
using SIGCM.Domain.Interfaces;
|
||||||
@@ -14,11 +13,13 @@ public class ReportsController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IListingRepository _listingRepo;
|
private readonly IListingRepository _listingRepo;
|
||||||
private readonly AuditRepository _auditRepo;
|
private readonly AuditRepository _auditRepo;
|
||||||
|
private readonly IReportRepository _reportRepo;
|
||||||
|
|
||||||
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo)
|
public ReportsController(IListingRepository listingRepo, AuditRepository auditRepo, IReportRepository reportRepo)
|
||||||
{
|
{
|
||||||
_listingRepo = listingRepo;
|
_listingRepo = listingRepo;
|
||||||
_auditRepo = auditRepo;
|
_auditRepo = auditRepo;
|
||||||
|
_reportRepo = reportRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("dashboard")]
|
[HttpGet("dashboard")]
|
||||||
@@ -148,4 +149,16 @@ public class ReportsController : ControllerBase
|
|||||||
var cajeros = await _listingRepo.GetActiveCashiersAsync();
|
var cajeros = await _listingRepo.GetActiveCashiersAsync();
|
||||||
return Ok(cajeros);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,23 +2,20 @@ namespace SIGCM.Domain.Entities;
|
|||||||
|
|
||||||
public class OrderItem
|
public class OrderItem
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int OrderId { get; set; }
|
public int OrderId { get; set; }
|
||||||
public int ProductId { get; set; }
|
public int ProductId { get; set; }
|
||||||
public int CompanyId { get; set; } // Para facturación cruzada
|
public int ServiceCompanyId { get; set; } // El que publica
|
||||||
|
public int BillingCompanyId { get; set; } // El que factura
|
||||||
// Vinculación polimórfica (Ej: ID del Listing que se acaba de crear)
|
|
||||||
public int? RelatedEntityId { get; set; }
|
public int? RelatedEntityId { get; set; }
|
||||||
public string? RelatedEntityType { get; set; } // 'Listing', 'Merchandise', 'RadioSpot'
|
public string? RelatedEntityType { get; set; }
|
||||||
|
|
||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
public decimal TaxRate { get; set; }
|
public decimal TaxRate { get; set; }
|
||||||
public decimal SubTotal { get; set; }
|
public decimal SubTotal { get; set; }
|
||||||
|
public decimal CommissionPercentage { get; set; }
|
||||||
public decimal CommissionPercentage { get; set; }
|
public decimal CommissionAmount { get; set; }
|
||||||
public decimal CommissionAmount { get; set; }
|
public string? ProductName { get; set; }
|
||||||
|
|
||||||
// Auxiliar
|
|
||||||
public string? ProductName { get; set; }
|
|
||||||
}
|
}
|
||||||
12
src/SIGCM.Domain/Entities/ProductPrice.cs
Normal file
12
src/SIGCM.Domain/Entities/ProductPrice.cs
Normal 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;
|
||||||
|
}
|
||||||
9
src/SIGCM.Domain/Entities/PublicationBlackout.cs
Normal file
9
src/SIGCM.Domain/Entities/PublicationBlackout.cs
Normal 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; }
|
||||||
|
}
|
||||||
11
src/SIGCM.Domain/Interfaces/ICalendarRepository.cs
Normal file
11
src/SIGCM.Domain/Interfaces/ICalendarRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
12
src/SIGCM.Domain/Interfaces/ICompanyRepository.cs
Normal file
12
src/SIGCM.Domain/Interfaces/ICompanyRepository.cs
Normal 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)
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ public interface IProductRepository
|
|||||||
Task<IEnumerable<ProductBundle>> GetBundleComponentsAsync(int parentProductId);
|
Task<IEnumerable<ProductBundle>> GetBundleComponentsAsync(int parentProductId);
|
||||||
Task AddComponentToBundleAsync(ProductBundle bundle);
|
Task AddComponentToBundleAsync(ProductBundle bundle);
|
||||||
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
|
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
|
||||||
|
Task<decimal> GetCurrentPriceAsync(int productId, DateTime date);
|
||||||
|
Task AddPriceAsync(ProductPrice price);
|
||||||
}
|
}
|
||||||
14
src/SIGCM.Domain/Interfaces/IReportRepository.cs
Normal file
14
src/SIGCM.Domain/Interfaces/IReportRepository.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAdvertisingRepository, AdvertisingRepository>();
|
services.AddScoped<IAdvertisingRepository, AdvertisingRepository>();
|
||||||
services.AddScoped<ISellerRepository, SellerRepository>();
|
services.AddScoped<ISellerRepository, SellerRepository>();
|
||||||
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
|
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)
|
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
||||||
services.AddScoped<MercadoPagoService>(sp =>
|
services.AddScoped<MercadoPagoService>(sp =>
|
||||||
|
|||||||
43
src/SIGCM.Infrastructure/Repositories/CalendarRepository.cs
Normal file
43
src/SIGCM.Infrastructure/Repositories/CalendarRepository.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/SIGCM.Infrastructure/Repositories/CompanyRepository.cs
Normal file
53
src/SIGCM.Infrastructure/Repositories/CompanyRepository.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,4 +126,39 @@ public class ProductRepository : IProductRepository
|
|||||||
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId",
|
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId",
|
||||||
new { ParentId = bundleId, ChildId = childProductId });
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
37
src/SIGCM.Infrastructure/Repositories/ReportRepository.cs
Normal file
37
src/SIGCM.Infrastructure/Repositories/ReportRepository.cs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,32 +11,28 @@ public class OrderService : IOrderService
|
|||||||
private readonly IProductRepository _productRepo;
|
private readonly IProductRepository _productRepo;
|
||||||
private readonly ISellerRepository _sellerRepo;
|
private readonly ISellerRepository _sellerRepo;
|
||||||
private readonly IClientProfileRepository _clientRepo;
|
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;
|
_orderRepo = orderRepo;
|
||||||
_productRepo = productRepo;
|
_productRepo = productRepo;
|
||||||
_sellerRepo = sellerRepo;
|
_sellerRepo = sellerRepo;
|
||||||
_clientRepo = clientRepo;
|
_clientRepo = clientRepo;
|
||||||
|
_calendarRepo = calendarRepo;
|
||||||
|
_listingRepo = listingRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OrderResultDto> CreateOrderAsync(CreateOrderDto dto)
|
public async Task<OrderResultDto> CreateOrderAsync(CreateOrderDto dto)
|
||||||
{
|
{
|
||||||
// 1. Preparar la cabecera
|
// 1. OBTENER CONFIGURACIÓN DEL VENDEDOR
|
||||||
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
|
|
||||||
decimal sellerCommissionRate = 0;
|
decimal sellerCommissionRate = 0;
|
||||||
var sellerProfile = await _sellerRepo.GetProfileAsync(dto.SellerId);
|
var sellerProfile = await _sellerRepo.GetProfileAsync(dto.SellerId);
|
||||||
if (sellerProfile != null && sellerProfile.IsActive)
|
if (sellerProfile != null && sellerProfile.IsActive)
|
||||||
@@ -44,179 +40,221 @@ public class OrderService : IOrderService
|
|||||||
sellerCommissionRate = sellerProfile.BaseCommissionPercentage;
|
sellerCommissionRate = sellerProfile.BaseCommissionPercentage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VALIDACIÓN DE CRÉDITO ---
|
// 2. VALIDACIÓN DE CRÉDITO (Si no es pago directo)
|
||||||
if (!dto.IsDirectPayment) // Si es Cuenta Corriente
|
if (!dto.IsDirectPayment)
|
||||||
{
|
{
|
||||||
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
|
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
|
||||||
|
|
||||||
// 1. Verificar si existe perfil financiero
|
|
||||||
if (profile == null)
|
if (profile == null)
|
||||||
{
|
throw new InvalidOperationException("El cliente no tiene habilitada la Cuenta Corriente.");
|
||||||
// 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).");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Verificar bloqueo manual (Legales, Mora, etc)
|
|
||||||
if (profile.IsCreditBlocked)
|
if (profile.IsCreditBlocked)
|
||||||
{
|
throw new InvalidOperationException($"Crédito BLOQUEADO. Motivo: {profile.BlockReason}");
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. PROCESAMIENTO DE ÍTEMS
|
||||||
var orderItems = new List<OrderItem>();
|
var orderItems = new List<OrderItem>();
|
||||||
decimal totalNet = 0;
|
decimal totalNet = 0;
|
||||||
decimal totalTax = 0;
|
decimal totalTax = 0;
|
||||||
|
|
||||||
// 2. Procesar ítems y calcular montos reales
|
|
||||||
foreach (var itemDto in dto.Items)
|
foreach (var itemDto in dto.Items)
|
||||||
{
|
{
|
||||||
var product = await _productRepo.GetByIdAsync(itemDto.ProductId);
|
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")
|
if (product.TypeCode == "BUNDLE")
|
||||||
{
|
{
|
||||||
// 1. Obtener componentes
|
// --- LÓGICA DE SETTLEMENT (Facturación Cruzada) ---
|
||||||
var components = await _productRepo.GetBundleComponentsAsync(product.Id);
|
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
|
// La empresa que factura es la dueña del Combo
|
||||||
// El precio al que vendemos el combo (ej: $1000)
|
int billingCompanyId = product.CompanyId;
|
||||||
decimal bundleSellPrice = product.BasePrice;
|
|
||||||
|
|
||||||
// La suma de los precios de lista de los componentes (ej: $600 + $600 = $1200)
|
// Calcular suma de precios vigentes de los hijos para prorrateo
|
||||||
decimal totalComponentsBasePrice = components.Sum(c => c.ChildProduct!.BasePrice * c.Quantity);
|
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)
|
foreach (var comp in components)
|
||||||
{
|
{
|
||||||
var child = comp.ChildProduct!;
|
var child = comp.ChildProduct!;
|
||||||
|
decimal childCurrentPrice = await _productRepo.GetCurrentPriceAsync(child.Id, DateTime.UtcNow);
|
||||||
|
|
||||||
// Lógica de Prorrateo:
|
// 1. Calcular Precio Prorrateado
|
||||||
// (PrecioBaseHijo / PrecioBaseTotalHijos) * PrecioVentaCombo
|
|
||||||
// Ej: (600 / 1200) * 1000 = $500
|
|
||||||
decimal allocatedUnitPrice;
|
decimal allocatedUnitPrice;
|
||||||
|
|
||||||
if (comp.FixedAllocationAmount.HasValue)
|
if (comp.FixedAllocationAmount.HasValue)
|
||||||
{
|
{
|
||||||
allocatedUnitPrice = comp.FixedAllocationAmount.Value;
|
allocatedUnitPrice = comp.FixedAllocationAmount.Value;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
decimal ratio = child.BasePrice / totalComponentsBasePrice;
|
// Regla de 3 simple sobre el precio del combo
|
||||||
allocatedUnitPrice = bundleSellPrice * ratio;
|
decimal ratio = childCurrentPrice / totalComponentsCurrentPrice;
|
||||||
|
allocatedUnitPrice = currentUnitPrice * ratio;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calcular línea para el componente
|
// 2. Calcular Totales de Línea
|
||||||
decimal qty = itemDto.Quantity * comp.Quantity; // Cantidad pedida * Cantidad en combo
|
decimal qty = itemDto.Quantity * comp.Quantity;
|
||||||
decimal subTotal = allocatedUnitPrice * qty;
|
decimal subTotalLine = allocatedUnitPrice * qty;
|
||||||
decimal taxAmount = subTotal * (child.TaxRate / 100m);
|
decimal taxAmountLine = subTotalLine * (child.TaxRate / 100m);
|
||||||
|
decimal commissionLine = subTotalLine * (sellerCommissionRate / 100m);
|
||||||
|
|
||||||
totalNet += subTotal;
|
// 3. Acumular a Totales de Orden
|
||||||
totalTax += taxAmount;
|
totalNet += subTotalLine;
|
||||||
|
totalTax += taxAmountLine;
|
||||||
|
|
||||||
|
// 4. Crear Item
|
||||||
orderItems.Add(new OrderItem
|
orderItems.Add(new OrderItem
|
||||||
{
|
{
|
||||||
ProductId = child.Id,
|
ProductId = child.Id,
|
||||||
CompanyId = child.CompanyId,
|
BillingCompanyId = billingCompanyId, // Cobra EL DÍA
|
||||||
|
ServiceCompanyId = child.CompanyId, // Presta EL PLATA
|
||||||
Quantity = qty,
|
Quantity = qty,
|
||||||
UnitPrice = allocatedUnitPrice,
|
UnitPrice = allocatedUnitPrice,
|
||||||
TaxRate = child.TaxRate,
|
TaxRate = child.TaxRate,
|
||||||
SubTotal = subTotal + taxAmount,
|
SubTotal = subTotalLine + taxAmountLine,
|
||||||
|
CommissionPercentage = sellerCommissionRate,
|
||||||
|
CommissionAmount = commissionLine,
|
||||||
RelatedEntityId = itemDto.RelatedEntityId,
|
RelatedEntityId = itemDto.RelatedEntityId,
|
||||||
RelatedEntityType = itemDto.RelatedEntityType,
|
RelatedEntityType = itemDto.RelatedEntityType,
|
||||||
ProductName = $"{product.Name} > {child.Name}" // Traza para saber que vino de un combo
|
ProductName = $"{product.Name} > {child.Name}" // Traza visual
|
||||||
// La comisión se calculará después si es necesario
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// --- LÓGICA ESTÁNDAR (PRODUCTO NORMAL) ---
|
// --- PRODUCTO SIMPLE ---
|
||||||
decimal unitPrice = product.BasePrice;
|
decimal subTotalLine = currentUnitPrice * itemDto.Quantity;
|
||||||
decimal subTotalNet = unitPrice * itemDto.Quantity;
|
decimal taxAmountLine = subTotalLine * (product.TaxRate / 100m);
|
||||||
decimal taxAmount = subTotalNet * (product.TaxRate / 100m);
|
decimal commissionLine = subTotalLine * (sellerCommissionRate / 100m);
|
||||||
|
|
||||||
totalNet += subTotalNet;
|
totalNet += subTotalLine;
|
||||||
totalTax += taxAmount;
|
totalTax += taxAmountLine;
|
||||||
|
|
||||||
// Cálculo Comisión Vendedor
|
|
||||||
decimal commissionAmt = subTotalNet * (sellerCommissionRate / 100m);
|
|
||||||
|
|
||||||
orderItems.Add(new OrderItem
|
orderItems.Add(new OrderItem
|
||||||
{
|
{
|
||||||
ProductId = product.Id,
|
ProductId = product.Id,
|
||||||
CompanyId = product.CompanyId,
|
BillingCompanyId = product.CompanyId, // Cobra el mismo que presta
|
||||||
|
ServiceCompanyId = product.CompanyId,
|
||||||
Quantity = itemDto.Quantity,
|
Quantity = itemDto.Quantity,
|
||||||
UnitPrice = unitPrice,
|
UnitPrice = currentUnitPrice,
|
||||||
TaxRate = product.TaxRate,
|
TaxRate = product.TaxRate,
|
||||||
SubTotal = subTotalNet + taxAmount,
|
SubTotal = subTotalLine + taxAmountLine,
|
||||||
|
CommissionPercentage = sellerCommissionRate,
|
||||||
|
CommissionAmount = commissionLine,
|
||||||
RelatedEntityId = itemDto.RelatedEntityId,
|
RelatedEntityId = itemDto.RelatedEntityId,
|
||||||
RelatedEntityType = itemDto.RelatedEntityType,
|
RelatedEntityType = itemDto.RelatedEntityType,
|
||||||
CommissionPercentage = sellerCommissionRate,
|
|
||||||
CommissionAmount = commissionAmt,
|
|
||||||
ProductName = product.Name
|
ProductName = product.Name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Finalizar totales de cabecera
|
// 4. VALIDACIÓN DE LÍMITE DE CRÉDITO (CHECK FINAL)
|
||||||
order.TotalNet = totalNet;
|
var totalOrderAmount = totalNet + totalTax;
|
||||||
order.TotalTax = totalTax;
|
|
||||||
order.TotalAmount = totalNet + totalTax;
|
|
||||||
|
|
||||||
// --- VALIDACIÓN DE LÍMITE (Post-Cálculo) ---
|
|
||||||
if (!dto.IsDirectPayment)
|
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);
|
var profile = await _clientRepo.GetProfileAsync(dto.ClientId);
|
||||||
if (profile != null)
|
if (profile != null)
|
||||||
{
|
{
|
||||||
var currentDebt = await _clientRepo.CalculateCurrentDebtAsync(dto.ClientId);
|
order.DueDate = DateTime.UtcNow.AddDays(profile.PaymentTermsDays);
|
||||||
var totalAmount = totalNet + totalTax;
|
}
|
||||||
var newTotalDebt = currentDebt + totalAmount;
|
}
|
||||||
|
|
||||||
if (newTotalDebt > profile.CreditLimit)
|
// 6. PERSISTENCIA TRANSACCIONAL
|
||||||
{
|
var orderId = await _orderRepo.CreateOrderAsync(order, orderItems);
|
||||||
// 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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si pasa, asignamos fecha de vencimiento automática si no vino en el DTO
|
// 7. POST-PROCESAMIENTO: ACTIVAR AVISOS
|
||||||
if (!order.DueDate.HasValue)
|
// 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
|
return new OrderResultDto
|
||||||
{
|
{
|
||||||
OrderId = orderId,
|
OrderId = orderId,
|
||||||
OrderNumber = order.OrderNumber,
|
OrderNumber = order.OrderNumber, // El repo actualiza el objeto order con el número generado
|
||||||
TotalAmount = order.TotalAmount,
|
TotalAmount = order.TotalAmount,
|
||||||
Status = order.PaymentStatus
|
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()}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user