Compare commits
11 Commits
29aa8e30e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 284ec7add6 | |||
| b4fa74ad9b | |||
| a8b8229b41 | |||
| f3638195a6 | |||
| b8f1ed8a68 | |||
| da99fd5843 | |||
| a9ad545fbb | |||
| e21028ee9f | |||
| 16f84237fb | |||
| 6d1eb908a0 | |||
| 841cc961b5 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,7 +38,7 @@ yarn-error.log*
|
||||
|
||||
# Configuración de desarrollo que puede contener secretos.
|
||||
# Es mejor usar "User Secrets" en desarrollo para las claves.
|
||||
appsettings.Development.json
|
||||
#appsettings.Development.json
|
||||
|
||||
# Archivos de publicación de Visual Studio
|
||||
[Pp]roperties/[Pp]ublish[Pp]rofiles/
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1 +0,0 @@
|
||||
System.Console.WriteLine(BCrypt.Net.BCrypt.HashPassword("1234"));
|
||||
@@ -1,13 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-panel</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -14,6 +14,7 @@ import AuditTimeline from './pages/Audit/AuditTimeline';
|
||||
import ClientManager from './pages/Clients/ClientManager';
|
||||
import CouponsPage from './pages/Coupons/CouponsPage';
|
||||
import ProductManager from './pages/Products/ProductManager';
|
||||
import ProductTypeManager from './pages/Products/ProductTypeManager';
|
||||
import CompanyManager from './pages/Companies/CompanyManager';
|
||||
import CreditManager from './pages/Finance/CreditManager';
|
||||
import CalendarManager from './pages/Companies/CalendarManager';
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
<Route path="/users" element={<UserManager />} />
|
||||
<Route path="/diagram" element={<DiagramPage />} />
|
||||
<Route path="/products" element={<ProductManager />} />
|
||||
<Route path="/product-types" element={<ProductTypeManager />} />
|
||||
<Route path="/pricing" element={<PricingManager />} />
|
||||
<Route path="/promotions" element={<PromotionsManager />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { X, Save, Building2, MapPin, Link } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import CuitInput from '../Shared/CuitInput';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
company: Company | null;
|
||||
@@ -11,6 +13,7 @@ interface Props {
|
||||
|
||||
export default function CompanyModal({ company, onClose }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCuitValid, setIsCuitValid] = useState(true);
|
||||
const [formData, setFormData] = useState<Partial<Company>>({
|
||||
name: '',
|
||||
taxId: '',
|
||||
@@ -66,11 +69,15 @@ export default function CompanyModal({ company, onClose }: Props) {
|
||||
|
||||
<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 })} />
|
||||
<CuitInput
|
||||
label="CUIT / Tax ID"
|
||||
value={formData.taxId || ''}
|
||||
required
|
||||
onChange={(val, valid) => {
|
||||
setFormData({ ...formData, taxId: val });
|
||||
setIsCuitValid(valid);
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
@@ -103,8 +110,15 @@ export default function CompanyModal({ company, onClose }: Props) {
|
||||
|
||||
<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"
|
||||
type="submit"
|
||||
// Bloquear si está cargando O si el CUIT es inválido (y tiene algo escrito)
|
||||
disabled={loading || !isCuitValid || !formData.taxId}
|
||||
className={clsx(
|
||||
"w-full py-4 rounded-xl font-black text-xs uppercase tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
|
||||
loading || !isCuitValid
|
||||
? "bg-slate-300 text-slate-500 cursor-not-allowed shadow-none"
|
||||
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Guardando...' : 'Guardar Empresa'}
|
||||
</button>
|
||||
|
||||
@@ -1,58 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Product, ProductBundleComponent } from '../../types/Product';
|
||||
import type { Product, ProductBundleComponent, ProductType } from '../../types/Product';
|
||||
import type { Company } from '../../types/Company';
|
||||
import type { Category } from '../../types/Category';
|
||||
import { productService } from '../../services/productService';
|
||||
import { productTypeService } from '../../services/productTypeService';
|
||||
import { X, Save, Layers, Plus, Trash2, AlertCircle, Package } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Props {
|
||||
product: Product | null;
|
||||
companies: Company[];
|
||||
categories: Category[];
|
||||
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) {
|
||||
export default function ProductModal({ product, companies, categories, allProducts, onClose }: Props) {
|
||||
const [formData, setFormData] = useState<Partial<Product>>({
|
||||
name: '',
|
||||
description: '',
|
||||
companyId: 0,
|
||||
productTypeId: 4, // Default Physical
|
||||
categoryId: undefined,
|
||||
productTypeId: 0,
|
||||
basePrice: 0,
|
||||
priceDurationDays: 1,
|
||||
taxRate: 21,
|
||||
sku: '',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const [productTypes, setProductTypes] = useState<ProductType[]>([]);
|
||||
const [isBundle, setIsBundle] = useState(false);
|
||||
const [bundleComponents, setBundleComponents] = useState<ProductBundleComponent[]>([]);
|
||||
const [newComponentId, setNewComponentId] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasDurationUI, setHasDurationUI] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
productTypeService.getAll().then(setProductTypes).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData(product);
|
||||
const type = PRODUCT_TYPES.find(t => t.id === product.productTypeId);
|
||||
if (type?.code === 'BUNDLE') {
|
||||
setIsBundle(true);
|
||||
loadBundleComponents(product.id);
|
||||
setHasDurationUI(product.priceDurationDays > 1);
|
||||
if (productTypes.length > 0) {
|
||||
const type = productTypes.find(t => t.id === product.productTypeId);
|
||||
if (type?.isBundle) {
|
||||
setIsBundle(true);
|
||||
loadBundleComponents(product.id);
|
||||
}
|
||||
}
|
||||
} else if (productTypes.length > 0 && formData.productTypeId === 0) {
|
||||
// Default to first type if creating new
|
||||
setFormData(f => ({ ...f, productTypeId: productTypes[0].id }));
|
||||
}
|
||||
}, [product]);
|
||||
}, [product, productTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const type = PRODUCT_TYPES.find(t => t.id === formData.productTypeId);
|
||||
setIsBundle(type?.code === 'BUNDLE');
|
||||
}, [formData.productTypeId]);
|
||||
const type = productTypes.find(t => t.id === formData.productTypeId);
|
||||
if (!type) return;
|
||||
|
||||
setIsBundle(type.isBundle);
|
||||
|
||||
// Si el tipo de producto NO soporta duración, forzamos duración 1
|
||||
if (!type.hasDuration) {
|
||||
if (formData.priceDurationDays !== 1) {
|
||||
setFormData(f => ({ ...f, priceDurationDays: 1 }));
|
||||
setHasDurationUI(false);
|
||||
}
|
||||
}
|
||||
}, [formData.productTypeId, productTypes]);
|
||||
|
||||
const loadBundleComponents = async (productId: number) => {
|
||||
try {
|
||||
@@ -137,7 +155,7 @@ export default function ProductModal({ product, companies, allProducts, onClose
|
||||
<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 => (
|
||||
{productTypes.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
onClick={() => setFormData({ ...formData, productTypeId: type.id })}
|
||||
@@ -169,12 +187,55 @@ export default function ProductModal({ product, companies, allProducts, onClose
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Rubro Asociado</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.categoryId || 0} onChange={e => setFormData({ ...formData, categoryId: Number(e.target.value) || undefined })}
|
||||
>
|
||||
<option value="0">Ninguno (Producto General)</option>
|
||||
{categories.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>
|
||||
|
||||
{productTypes.find(t => t.id === formData.productTypeId)?.hasDuration && (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div
|
||||
onClick={() => {
|
||||
const newValue = !hasDurationUI;
|
||||
setHasDurationUI(newValue);
|
||||
if (!newValue) setFormData({ ...formData, priceDurationDays: 1 });
|
||||
}}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${hasDurationUI ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest leading-none">Precio por Duración</span>
|
||||
<span className="text-[9px] text-slate-400 font-bold mt-1">Habilitar si el precio base cubre varios días</span>
|
||||
</div>
|
||||
<div className={`w-10 h-5 rounded-full relative transition-colors ${hasDurationUI ? 'bg-blue-600' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDurationUI ? 'right-1' : 'left-1'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDurationUI && (
|
||||
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="space-y-1.5 pl-2 border-l-2 border-blue-200 ml-4">
|
||||
<label className="text-[10px] font-black text-blue-500 uppercase tracking-widest ml-1">Cantidad de Días</label>
|
||||
<div className="relative">
|
||||
<input required type="number" min="2" className="w-full p-3 bg-blue-50/50 border-2 border-blue-100 rounded-xl outline-none focus:border-blue-500 font-black text-sm text-blue-600"
|
||||
value={formData.priceDurationDays} onChange={e => setFormData({ ...formData, priceDurationDays: Math.max(2, parseInt(e.target.value) || 2) })} />
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] font-black text-blue-300 uppercase">Días</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</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"
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ProductType } from '../../types/Product';
|
||||
import { productTypeService } from '../../services/productTypeService';
|
||||
import { X, Save, Settings2 } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface Props {
|
||||
type: ProductType | null;
|
||||
onClose: (refresh?: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ProductTypeModal({ type, onClose }: Props) {
|
||||
const [formData, setFormData] = useState<Partial<ProductType>>({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'Package',
|
||||
hasDuration: false,
|
||||
requiresText: false,
|
||||
requiresCategory: false,
|
||||
isBundle: false,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (type) setFormData(type);
|
||||
}, [type]);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (type) await productTypeService.update(type.id, formData);
|
||||
else await productTypeService.create(formData);
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
alert("Error al guardar tipo de producto");
|
||||
} 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-xl 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">
|
||||
{type ? `Configurar: ${type.name}` : 'Nuevo Tipo de Producto'}
|
||||
</h3>
|
||||
<button onClick={() => onClose()} className="p-2 hover:bg-white rounded-xl transition-colors text-slate-400"><X /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="p-8 overflow-y-auto custom-scrollbar space-y-6">
|
||||
<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">Código Único (Backend)</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"
|
||||
placeholder="EJ: AV_CLASIFICADO"
|
||||
disabled={!!type}
|
||||
value={formData.code} onChange={e => setFormData({ ...formData, code: e.target.value.toUpperCase() })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nombre Display</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"
|
||||
placeholder="EJ: Aviso Clasificado"
|
||||
value={formData.name} onChange={e => setFormData({ ...formData, name: 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">Descripción</label>
|
||||
<textarea 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 resize-none h-20"
|
||||
value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50/50 p-6 rounded-2xl border border-blue-100 space-y-4">
|
||||
<h4 className="flex items-center gap-2 text-[10px] font-black text-blue-600 uppercase tracking-widest mb-2">
|
||||
<Settings2 size={14} /> Parámetros de Comportamiento
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{[
|
||||
{ key: 'hasDuration', label: '¿Soporta Duración?', desc: 'Habilita el parámetro de selección de días/módulos.' },
|
||||
{ key: 'requiresText', label: '¿Requiere Texto?', desc: 'Habilita el editor de texto para composición de avisos.' },
|
||||
{ key: 'requiresCategory', label: '¿Obligatorio asignar Rubro?', desc: 'El producto debe estar vinculado a un rubro del árbol.' },
|
||||
{ key: 'isBundle', label: '¿Es un Combo?', desc: 'Habilita la gestión de componentes hijos para este producto.' }
|
||||
].map(opt => (
|
||||
<div
|
||||
key={opt.key}
|
||||
onClick={() => setFormData({ ...formData, [opt.key]: !formData[opt.key as keyof ProductType] })}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border-2 cursor-pointer transition-all ${formData[opt.key as keyof ProductType] ? 'border-blue-600 bg-white' : 'border-slate-100 bg-slate-50/50 grayscale opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs font-black text-slate-700 uppercase">{opt.label}</p>
|
||||
<p className="text-[10px] text-slate-400 font-bold">{opt.desc}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-5 rounded-full relative transition-colors ${formData[opt.key as keyof ProductType] ? 'bg-blue-600' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${formData[opt.key as keyof ProductType] ? 'right-1' : 'left-1'}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button type="button" onClick={() => onClose()} className="px-6 py-3 rounded-xl font-bold text-slate-500 hover:bg-slate-50 transition-all text-xs uppercase tracking-wider">Cancelar</button>
|
||||
<button
|
||||
type="submit" 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 Configuración'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/admin-panel/src/components/Shared/CuitInput.tsx
Normal file
98
frontend/admin-panel/src/components/Shared/CuitInput.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||
import { isValidCuit, formatCuit } from '../../utils/cuitValidator';
|
||||
import { ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CuitInputProps {
|
||||
value: string;
|
||||
onChange: (value: string, isValid: boolean) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
label?: string; // Opcional, para renderizar el label dentro del componente
|
||||
}
|
||||
|
||||
export default function CuitInput({
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
placeholder = "20-12345678-9",
|
||||
className,
|
||||
label
|
||||
}: CuitInputProps) {
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
|
||||
// Validar al montar o cuando cambia el value externamente
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setIsValid(isValidCuit(value));
|
||||
} else {
|
||||
setIsValid(!required); // Si está vacío y no es requerido, es válido
|
||||
}
|
||||
}, [value, required]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value;
|
||||
const formatted = formatCuit(rawValue);
|
||||
|
||||
// Solo permitir hasta 13 caracteres (11 dígitos + 2 guiones)
|
||||
if (formatted.length > 13) return;
|
||||
|
||||
const valid = isValidCuit(formatted);
|
||||
|
||||
setIsTouched(true);
|
||||
setIsValid(valid);
|
||||
|
||||
// Propagamos el cambio al padre
|
||||
onChange(formatted, valid);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsTouched(true);
|
||||
};
|
||||
|
||||
const showError = isTouched && !isValid && value.length > 0;
|
||||
const showSuccess = isValid && value.length === 13;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-1.5 flex items-center gap-1">
|
||||
{label}
|
||||
{required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className={clsx(
|
||||
"w-full p-3 bg-slate-50 border-2 rounded-xl outline-none font-mono font-bold text-sm transition-all pr-10",
|
||||
showError
|
||||
? "border-rose-300 text-rose-700 focus:border-rose-500 bg-rose-50"
|
||||
: showSuccess
|
||||
? "border-emerald-300 text-emerald-700 focus:border-emerald-500 bg-emerald-50/30"
|
||||
: "border-slate-100 text-slate-700 focus:border-blue-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Icono de estado absoluto a la derecha */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{showError && <ShieldAlert size={18} className="text-rose-500 animate-pulse" />}
|
||||
{showSuccess && <ShieldCheck size={18} className="text-emerald-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<p className="text-[10px] font-bold text-rose-500 mt-1 ml-1 uppercase tracking-tight">
|
||||
CUIT inválido (Verifique dígito verificador)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -49,20 +49,21 @@ export default function ProtectedLayout() {
|
||||
{
|
||||
title: "Gestión Diaria",
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Moderación', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
||||
{ label: 'Explorador', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
||||
{ label: 'Inicio', href: '/', icon: <LayoutDashboard size={18} />, roles: ['Admin', 'Cajero'] },
|
||||
{ label: 'Moderación de Avisos', href: '/moderation', icon: <Eye size={18} />, roles: ['Admin', 'Moderador'], badge: unreadCount },
|
||||
{ label: 'Buscar Publicaciones', href: '/listings', icon: <Search size={18} />, roles: ['Admin', 'Cajero', 'Moderador'] },
|
||||
{ label: 'Clientes', href: '/clients', icon: <ClientIcon size={18} />, roles: ['Admin', 'Cajero'] },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Comercial & Catálogo",
|
||||
title: "Catálogo & Precios",
|
||||
items: [
|
||||
{ label: 'Productos', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Tarifas', href: '/pricing', icon: <DollarSign size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Productos & Tarifas', href: '/products', icon: <Box size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Configuración de Tipos', href: '/product-types', icon: <Settings size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Reglas por Rubro', 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'] },
|
||||
{ label: 'Cupones de Descuento', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Rubros & Categorías', href: '/categories', icon: <FolderTree size={18} />, roles: ['Admin'] },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -70,16 +71,16 @@ export default function ProtectedLayout() {
|
||||
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'] },
|
||||
{ label: 'Liquidación de Cuentas', href: '/reports/settlement', icon: <ArrowRightLeft size={18} />, roles: ['Admin', 'Contador'] },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Operaciones & Sistema",
|
||||
title: "Sistemas & Administración",
|
||||
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'] },
|
||||
{ label: 'Diagramación de Página', href: '/diagram', icon: <FileText size={18} />, roles: ['Admin', 'Diagramador'] },
|
||||
{ label: 'Calendario de Ediciones', href: '/companies/calendar', icon: <Calendar size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Gestión de Usuarios', href: '/users', icon: <Users size={18} />, roles: ['Admin'] },
|
||||
{ label: 'Registro de Auditoría', href: '/audit', icon: <History size={18} />, roles: ['Admin'] },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
import { Save, DollarSign, FileText, Type, AlertCircle } from 'lucide-react';
|
||||
import { Save, DollarSign, FileText, Package, ExternalLink, ArrowRight, Plus, Trash2, Info, AlertCircle } from 'lucide-react';
|
||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||
|
||||
interface PricingConfig {
|
||||
// Datos mínimos de un Producto del Catálogo
|
||||
interface ProductSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
basePrice: number;
|
||||
}
|
||||
|
||||
interface WordRange {
|
||||
id: number;
|
||||
fromCount: number;
|
||||
toCount: number;
|
||||
pricePerWord: number;
|
||||
}
|
||||
|
||||
interface PricingConfig {
|
||||
id: number;
|
||||
categoryId: number;
|
||||
baseWordCount: number;
|
||||
extraWordPrice: number;
|
||||
specialChars: string;
|
||||
specialCharPrice: number;
|
||||
boldSurcharge: number;
|
||||
frameSurcharge: number;
|
||||
wordRanges: WordRange[];
|
||||
}
|
||||
|
||||
// Configuración por defecto
|
||||
const defaultConfig: PricingConfig = {
|
||||
basePrice: 0, baseWordCount: 15, extraWordPrice: 0,
|
||||
specialChars: '!', specialCharPrice: 0, boldSurcharge: 0, frameSurcharge: 0
|
||||
id: 0,
|
||||
categoryId: 0,
|
||||
baseWordCount: 15,
|
||||
extraWordPrice: 0,
|
||||
specialChars: '!',
|
||||
specialCharPrice: 0,
|
||||
boldSurcharge: 0,
|
||||
frameSurcharge: 0,
|
||||
wordRanges: []
|
||||
};
|
||||
|
||||
export default function PricingManager() {
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [selectedCat, setSelectedCat] = useState<number | null>(null);
|
||||
const [categoryProducts, setCategoryProducts] = useState<ProductSummary[]>([]);
|
||||
const [loadingProducts, setLoadingProducts] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<PricingConfig>(defaultConfig);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -36,12 +63,21 @@ export default function PricingManager() {
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCat) {
|
||||
// Cargar config existente
|
||||
// Cargar config existente del rubro
|
||||
api.get(`/pricing/${selectedCat}`)
|
||||
.then(res => {
|
||||
if (res.data) setConfig(res.data);
|
||||
else setConfig(defaultConfig); // Reset si es nuevo
|
||||
else setConfig(defaultConfig);
|
||||
});
|
||||
|
||||
// Cargar productos vinculados a este rubro
|
||||
setLoadingProducts(true);
|
||||
api.get(`/products/by-category/${selectedCat}`)
|
||||
.then(res => setCategoryProducts(res.data || []))
|
||||
.catch(() => setCategoryProducts([]))
|
||||
.finally(() => setLoadingProducts(false));
|
||||
} else {
|
||||
setCategoryProducts([]);
|
||||
}
|
||||
}, [selectedCat]);
|
||||
|
||||
@@ -65,7 +101,7 @@ export default function PricingManager() {
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-2xl font-bold mb-6 flex items-center gap-2 text-gray-800">
|
||||
<DollarSign className="text-green-600" />
|
||||
Gestor de Tarifas y Reglas
|
||||
Reglas de Tarifación por Rubro
|
||||
</h2>
|
||||
|
||||
{/* SELECTOR DE RUBRO */}
|
||||
@@ -82,7 +118,7 @@ export default function PricingManager() {
|
||||
<option
|
||||
key={cat.id}
|
||||
value={cat.id}
|
||||
disabled={!cat.isSelectable} // Bloqueamos padres para forzar config en hojas
|
||||
disabled={!cat.isSelectable}
|
||||
className={cat.isSelectable ? "text-gray-900 font-medium" : "text-gray-400 font-bold bg-gray-50"}
|
||||
>
|
||||
{'\u00A0\u00A0'.repeat(cat.level)}
|
||||
@@ -90,7 +126,6 @@ export default function PricingManager() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Flecha custom para estilo */}
|
||||
<div className="absolute right-4 top-3.5 pointer-events-none text-gray-500">▼</div>
|
||||
</div>
|
||||
|
||||
@@ -106,90 +141,234 @@ export default function PricingManager() {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* TARIFA BASE */}
|
||||
{/* TARJETA: PRECIO BASE - muestra productos vinculados */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
|
||||
<FileText size={20} className="text-blue-500" /> Tarifa Base (Texto)
|
||||
<h3 className="font-bold text-lg mb-1 flex items-center gap-2 text-gray-800">
|
||||
<Package size={20} className="text-blue-500" /> Precio Base del Rubro
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mb-4">
|
||||
El precio mínimo por aviso se define en cada Producto del Catálogo vinculado a este rubro.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Precio Mínimo ($)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">$</span>
|
||||
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={config.basePrice} onChange={e => setConfig({ ...config, basePrice: parseFloat(e.target.value) })} />
|
||||
{loadingProducts ? (
|
||||
<div className="text-xs text-slate-400 animate-pulse py-3">Cargando productos...</div>
|
||||
) : categoryProducts.length === 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<span className="text-amber-500 text-xl leading-none">⚠</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-amber-700">Sin productos vinculados</p>
|
||||
<p className="text-xs text-amber-600 mt-0.5">
|
||||
Este rubro no tiene productos en el Catálogo. El precio base será $0 al calcular tarifas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">Costo por el aviso básico por día.</p>
|
||||
<Link
|
||||
to="/products"
|
||||
className="inline-flex items-center gap-2 text-xs font-bold text-blue-600 hover:text-blue-800 transition-colors bg-blue-50 px-3 py-2 rounded-lg border border-blue-200"
|
||||
>
|
||||
<ExternalLink size={12} /> Ir al Catálogo de Productos para agregar uno
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{categoryProducts.map(prod => (
|
||||
<div key={prod.id} className="flex items-center justify-between py-2.5 px-3 bg-slate-50 rounded-lg border border-slate-100 hover:border-blue-200 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[9px] font-black uppercase text-slate-400 bg-slate-200 px-1.5 py-0.5 rounded">{prod.typeCode}</span>
|
||||
<span className="text-sm font-bold text-slate-700">{prod.name}</span>
|
||||
</div>
|
||||
<span className="font-mono font-black text-blue-600 text-sm">${prod.basePrice.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
to="/products"
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-[11px] font-bold text-blue-500 hover:text-blue-700 transition-colors"
|
||||
>
|
||||
<ArrowRight size={11} /> Administrar precios en el Catálogo de Productos
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* TARJETA: REGLAS POR PALABRAS */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 lg:col-span-2">
|
||||
<div className="flex justify-between items-center mb-4 pb-2 border-b border-gray-100">
|
||||
<h3 className="font-bold text-lg flex items-center gap-2 text-gray-800">
|
||||
<FileText size={20} className="text-blue-500" /> Tarifación por Cantidad de Palabras
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* CONFIGURACIÓN BÁSICA */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-bold text-gray-700 flex items-center gap-2">
|
||||
<Info size={14} className="text-blue-400" /> Configuración Base
|
||||
</h4>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Palabras Incluidas en Base</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={config.baseWordCount} onChange={e => setConfig({ ...config, baseWordCount: parseInt(e.target.value) })} />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Cantidad de palabras ya cubiertas por el Precio Base del Producto.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Costo Palabra Extra ($)</label>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Precio x Palabra (Fallback)</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
value={config.extraWordPrice} onChange={e => setConfig({ ...config, extraWordPrice: parseFloat(e.target.value) })} />
|
||||
<p className="text-[10px] text-gray-400 mt-1">Se usa si no se definen rangos o si el total supera los rangos definidos.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* EDITOR DE RANGOS */}
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-sm font-bold text-gray-700">Escalones de Precio (Palabras Extra)</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newRanges = [...config.wordRanges];
|
||||
const lastRange = newRanges[newRanges.length - 1];
|
||||
const from = lastRange ? lastRange.toCount + 1 : 1;
|
||||
newRanges.push({ id: 0, fromCount: from, toCount: from + 4, pricePerWord: 0 });
|
||||
setConfig({ ...config, wordRanges: newRanges });
|
||||
}}
|
||||
className="text-xs font-bold bg-blue-50 text-blue-600 px-3 py-1.5 rounded-lg hover:bg-blue-100 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Plus size={14} /> AGREGAR RANGO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.wordRanges.length === 0 ? (
|
||||
<div className="bg-slate-50 border border-dashed border-slate-200 rounded-xl p-8 text-center">
|
||||
<p className="text-xs text-slate-400 font-medium">No hay rangos definidos. Se cobrará siempre el precio de fallback por cada palabra extra.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.wordRanges.map((range, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 bg-white p-2 border rounded-xl shadow-sm animate-fade-in group">
|
||||
<div className="flex-1 grid grid-cols-4 gap-4 items-center px-2">
|
||||
<div className="text-center">
|
||||
<span className="text-[10px] uppercase font-black text-slate-300 block">DESDE</span>
|
||||
<span className="font-mono font-bold text-slate-600">{range.fromCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-black text-slate-300 block">HASTA</span>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border-b-2 border-slate-100 focus:border-blue-400 outline-none text-sm font-bold p-1 text-center"
|
||||
value={range.toCount}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
const newRanges = [...config.wordRanges];
|
||||
newRanges[idx].toCount = val;
|
||||
|
||||
// Actualizar automáticamente el "Desde" del siguiente rango para mantener continuidad
|
||||
for (let i = idx + 1; i < newRanges.length; i++) {
|
||||
newRanges[i].fromCount = newRanges[i - 1].toCount + 1;
|
||||
}
|
||||
setConfig({ ...config, wordRanges: newRanges });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-[10px] uppercase font-black text-slate-300 block">PRECIO X PALABRA</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400 font-bold">$</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="w-full border-b-2 border-slate-100 focus:border-green-400 outline-none text-sm font-black p-1 text-green-600"
|
||||
value={range.pricePerWord}
|
||||
onChange={(e) => {
|
||||
const newRanges = [...config.wordRanges];
|
||||
newRanges[idx].pricePerWord = parseFloat(e.target.value);
|
||||
setConfig({ ...config, wordRanges: newRanges });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newRanges = config.wordRanges.filter((_, i) => i !== idx);
|
||||
// Re-calcular continuidades
|
||||
if (newRanges.length > 0) {
|
||||
newRanges[0].fromCount = 1;
|
||||
for (let i = 1; i < newRanges.length; i++) {
|
||||
newRanges[i].fromCount = newRanges[i - 1].toCount + 1;
|
||||
}
|
||||
}
|
||||
setConfig({ ...config, wordRanges: newRanges });
|
||||
}}
|
||||
className="p-2 text-rose-300 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-amber-500 font-bold flex items-center gap-1 mt-3 px-1">
|
||||
<AlertCircle size={10} /> TIP: Usa un 'Hasta' muy alto (ej: 9999) en el último rango para cubrir todas las palabras extra restantes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CONTENIDO ESPECIAL */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
|
||||
<AlertCircle size={20} className="text-orange-500" /> Caracteres Especiales
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Caracteres a cobrar (ej: !$%)</label>
|
||||
<input type="text" className="border p-2 rounded w-full font-mono tracking-widest focus:ring-2 focus:ring-orange-500 outline-none"
|
||||
value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} />
|
||||
<p className="text-xs text-gray-400 mt-1">Cada uno de estos símbolos se cobrará aparte.</p>
|
||||
</div>
|
||||
{/* TARJETA: CARACTERES ESPECIALES */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
|
||||
<DollarSign size={20} className="text-orange-500" /> Caracteres Especiales
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Costo por Símbolo ($)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">$</span>
|
||||
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-orange-500 outline-none"
|
||||
value={config.specialCharPrice} onChange={e => setConfig({ ...config, specialCharPrice: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Caracteres a cobrar (ej: !$%)</label>
|
||||
<input type="text" className="border p-2 rounded w-full font-mono tracking-widest focus:ring-2 focus:ring-orange-500 outline-none"
|
||||
value={config.specialChars} onChange={e => setConfig({ ...config, specialChars: e.target.value })} />
|
||||
<p className="text-xs text-gray-400 mt-1">Cada uno de estos símbolos se cobrará aparte del precio por palabra.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Costo por Símbolo ($)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-2 text-gray-500">$</span>
|
||||
<input type="number" className="pl-6 border p-2 rounded w-full focus:ring-2 focus:ring-orange-500 outline-none"
|
||||
value={config.specialCharPrice} onChange={e => setConfig({ ...config, specialCharPrice: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ESTILOS VISUALES */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 lg:col-span-2">
|
||||
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
|
||||
<Type size={20} className="text-purple-500" /> Estilos Visuales (Recargos)
|
||||
</h3>
|
||||
{/* TARJETA: ESTILOS VISUALES */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="font-bold text-lg mb-4 pb-2 border-b border-gray-100 flex items-center gap-2 text-gray-800">
|
||||
<Save size={20} className="text-purple-500" /> Estilos Visuales (Recargos)
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
|
||||
<div className="font-bold text-xl px-3 py-1 border-2 border-transparent bg-white shadow-sm">N</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Negrita ($)</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
value={config.boldSurcharge} onChange={e => setConfig({ ...config, boldSurcharge: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
|
||||
<div className="font-bold text-xl px-3 py-1 border-2 border-transparent bg-white shadow-sm">N</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Negrita ($)</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
value={config.boldSurcharge} onChange={e => setConfig({ ...config, boldSurcharge: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
|
||||
<div className="font-bold text-xl px-3 py-1 border-2 border-black bg-white shadow-sm">A</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Recuadro ($)</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
value={config.frameSurcharge} onChange={e => setConfig({ ...config, frameSurcharge: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 bg-gray-50 p-4 rounded border border-gray-200">
|
||||
<div className="font-bold text-xl px-3 py-1 border-2 border-black bg-white shadow-sm">A</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">Recargo Recuadro ($)</label>
|
||||
<input type="number" className="border p-2 rounded w-full focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
value={config.frameSurcharge} onChange={e => setConfig({ ...config, frameSurcharge: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* BARRA DE ACCIÓN FLOTANTE */}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Search, Edit, Box, Layers } from 'lucide-react';
|
||||
import { Plus, Search, Edit, Box, Layers, Trash2 } from 'lucide-react';
|
||||
import { productService } from '../../../../counter-panel/src/services/productService';
|
||||
import { companyService } from '../../../../counter-panel/src/services/companyService';
|
||||
import { categoryService } from '../../services/categoryService';
|
||||
import type { Product } from '../../../../counter-panel/src/types/Product';
|
||||
import type { Company } from '../../../../counter-panel/src/types/Company';
|
||||
import type { Category } from '../../types/Category';
|
||||
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 [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
@@ -24,12 +27,14 @@ export default function ProductManager() {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [prodRes, compRes] = await Promise.all([
|
||||
const [prodRes, compRes, catRes] = await Promise.all([
|
||||
productService.getAll(),
|
||||
companyService.getAll()
|
||||
companyService.getAll(),
|
||||
categoryService.getAll()
|
||||
]);
|
||||
setProducts(prodRes);
|
||||
setCompanies(compRes);
|
||||
setCategories(catRes);
|
||||
} catch (error) {
|
||||
console.error("Error cargando catálogo", error);
|
||||
} finally {
|
||||
@@ -52,6 +57,21 @@ export default function ProductManager() {
|
||||
if (shouldRefresh) loadData();
|
||||
};
|
||||
|
||||
const handleDelete = async (product: Product) => {
|
||||
if (!window.confirm(`¿Está seguro que desea eliminar el producto "${product.name}"? Esta acción no se puede deshacer.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await productService.delete(product.id);
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
console.error("Error eliminando producto", error);
|
||||
const msg = error.response?.data?.message || "Ocurrió un error al intentar eliminar el producto.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(p =>
|
||||
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
p.sku?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
@@ -133,12 +153,22 @@ export default function ProductManager() {
|
||||
<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>
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleEdit(p)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(p)}
|
||||
className="p-2 text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="Eliminar"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -150,6 +180,7 @@ export default function ProductManager() {
|
||||
<ProductModal
|
||||
product={editingProduct}
|
||||
companies={companies}
|
||||
categories={categories}
|
||||
allProducts={products} // Pasamos todos los productos para poder armar combos
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
|
||||
139
frontend/admin-panel/src/pages/Products/ProductTypeManager.tsx
Normal file
139
frontend/admin-panel/src/pages/Products/ProductTypeManager.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings, Plus, Search, Edit, Trash2, Code, Layers, Type, Image, Mic, Package, LifeBuoy } from 'lucide-react';
|
||||
import type { ProductType } from '../../types/Product';
|
||||
import { productTypeService } from '../../services/productTypeService';
|
||||
import ProductTypeModal from '../../components/Products/ProductTypeModal';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'Type': Type,
|
||||
'Image': Image,
|
||||
'Mic': Mic,
|
||||
'Package': Package,
|
||||
'LifeBuoy': LifeBuoy,
|
||||
'Layers': Layers
|
||||
};
|
||||
|
||||
export default function ProductTypeManager() {
|
||||
const [types, setTypes] = useState<ProductType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingType, setEditingType] = useState<ProductType | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await productTypeService.getAll();
|
||||
setTypes(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingType(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (t: ProductType) => {
|
||||
setEditingType(t);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('¿Eliminar este tipo de producto? Solo es posible si no tiene productos asociados.')) return;
|
||||
try {
|
||||
await productTypeService.delete(id);
|
||||
loadData();
|
||||
} catch (e: any) {
|
||||
alert(e.response?.data || 'Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = types.filter(t =>
|
||||
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
t.code.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
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">
|
||||
<Settings className="text-blue-600" /> Tipos de Producto
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500 font-medium">Define el comportamiento y parámetros de las categorías de venta.</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 Tipo
|
||||
</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 Nombre 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>
|
||||
|
||||
<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 configuraciones...</div>
|
||||
) : filtered.map(t => {
|
||||
const IconComp = ICON_MAP[t.icon || 'Package'] || Package;
|
||||
return (
|
||||
<div key={t.id} className={clsx("bg-white p-6 rounded-[1.5rem] border-2 transition-all hover:shadow-lg group flex flex-col justify-between", t.isActive ? "border-slate-100" : "border-slate-100 opacity-60 grayscale")}>
|
||||
<div>
|
||||
<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">
|
||||
<IconComp size={24} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleEdit(t)} 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(t.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 uppercase tracking-tight">{t.name}</h3>
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Code size={12} className="text-slate-400" />
|
||||
<span className="text-[10px] font-mono font-bold text-slate-400">{t.code}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-medium mb-4 line-clamp-2">{t.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 border-t border-slate-50 pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{t.hasDuration && <span className="px-2 py-0.5 bg-blue-50 text-blue-600 rounded text-[9px] font-black uppercase tracking-widest border border-blue-100">Días</span>}
|
||||
{t.requiresText && <span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">Texto</span>}
|
||||
{t.requiresCategory && <span className="px-2 py-0.5 bg-amber-50 text-amber-600 rounded text-[9px] font-black uppercase tracking-widest border border-amber-100">Rubro</span>}
|
||||
{t.isBundle && <span className="px-2 py-0.5 bg-purple-50 text-purple-600 rounded text-[9px] font-black uppercase tracking-widest border border-purple-100">Combo</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<ProductTypeModal type={editingType} onClose={(refresh) => { setIsModalOpen(false); if (refresh) loadData(); }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
frontend/admin-panel/src/services/productTypeService.ts
Normal file
23
frontend/admin-panel/src/services/productTypeService.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import api from './api';
|
||||
import type { ProductType } from '../types/Product';
|
||||
|
||||
export const productTypeService = {
|
||||
getAll: async (): Promise<ProductType[]> => {
|
||||
const res = await api.get('/producttypes');
|
||||
return res.data;
|
||||
},
|
||||
getById: async (id: number): Promise<ProductType> => {
|
||||
const res = await api.get(`/producttypes/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (data: Partial<ProductType>): Promise<ProductType> => {
|
||||
const res = await api.post('/producttypes', data);
|
||||
return res.data;
|
||||
},
|
||||
update: async (id: number, data: Partial<ProductType>): Promise<void> => {
|
||||
await api.put(`/producttypes/${id}`, data);
|
||||
},
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/producttypes/${id}`);
|
||||
}
|
||||
};
|
||||
@@ -2,11 +2,13 @@ export interface Product {
|
||||
id: number;
|
||||
companyId: number;
|
||||
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
|
||||
categoryId?: number; // Para relacionarlo a un rubro
|
||||
name: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
externalId?: string;
|
||||
basePrice: number;
|
||||
priceDurationDays: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
@@ -25,4 +27,19 @@ export interface ProductBundleComponent {
|
||||
|
||||
// Datos del hijo para visualización
|
||||
childProduct?: Product;
|
||||
}
|
||||
|
||||
export interface ProductType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
|
||||
// Configuración dinámica
|
||||
hasDuration: boolean;
|
||||
requiresText: boolean;
|
||||
requiresCategory: boolean;
|
||||
isBundle: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
43
frontend/admin-panel/src/utils/cuitValidator.ts
Normal file
43
frontend/admin-panel/src/utils/cuitValidator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Valida un CUIT/CUIL argentino aplicando el algoritmo de Módulo 11.
|
||||
* Soporta formatos con o sin guiones (20-12345678-9 o 20123456789).
|
||||
*/
|
||||
export const isValidCuit = (cuit: string): boolean => {
|
||||
if (!cuit) return false;
|
||||
|
||||
// 1. Limpiar el input: dejar solo números
|
||||
const cleanCuit = cuit.replace(/[^0-9]/g, '');
|
||||
|
||||
// 2. Verificar longitud exacta (11 dígitos)
|
||||
if (cleanCuit.length !== 11) return false;
|
||||
|
||||
// 3. Convertir a array de números
|
||||
const digits = cleanCuit.split('').map(Number);
|
||||
|
||||
// 4. Algoritmo de verificación (Módulo 11)
|
||||
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
total += digits[i] * multipliers[i];
|
||||
}
|
||||
|
||||
const remainder = total % 11;
|
||||
const calculatedVerifier = remainder === 0 ? 0 : remainder === 1 ? 9 : 11 - remainder;
|
||||
const actualVerifier = digits[10];
|
||||
|
||||
// Caso especial: Cuando el resto es 1, el resultado suele ser 9 en algunos casos específicos
|
||||
// o se considera inválido y se debe cambiar el prefijo (Hombres/Mujeres),
|
||||
// pero para validación estricta matemática:
|
||||
return calculatedVerifier === actualVerifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea un string de números al formato XX-XXXXXXXX-X
|
||||
*/
|
||||
export const formatCuit = (cuit: string): string => {
|
||||
const nums = cuit.replace(/[^0-9]/g, '');
|
||||
if (nums.length <= 2) return nums;
|
||||
if (nums.length <= 10) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
|
||||
return `${nums.slice(0, 2)}-${nums.slice(2, 10)}-${nums.slice(10, 11)}`;
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>counter-panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>counter-panel</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
56
frontend/counter-panel/package-lock.json
generated
56
frontend/counter-panel/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "counter-panel",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
@@ -333,6 +336,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
|
||||
@@ -4,12 +4,15 @@ import { useDebounce } from '../../hooks/useDebounce';
|
||||
import api from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { processCategories, type FlatCategory } from '../../utils/categoryTreeUtils';
|
||||
import { productService } from '../../services/productService';
|
||||
import type { Product } from '../../types/Product';
|
||||
|
||||
interface AdEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (listingId: number, price: number, description: string) => void;
|
||||
clientId: number | null; // El aviso se vinculará a este cliente
|
||||
productId: number; // Necesario para el precio base
|
||||
}
|
||||
|
||||
interface PricingResult {
|
||||
@@ -18,9 +21,10 @@ interface PricingResult {
|
||||
details: string;
|
||||
}
|
||||
|
||||
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }: AdEditorModalProps) {
|
||||
export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId, productId }: AdEditorModalProps) {
|
||||
const [flatCategories, setFlatCategories] = useState<FlatCategory[]>([]);
|
||||
const [operations, setOperations] = useState<any[]>([]);
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [calculating, setCalculating] = useState(false);
|
||||
|
||||
@@ -52,9 +56,40 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [catRes, opRes] = await Promise.all([api.get('/categories'), api.get('/operations')]);
|
||||
setFlatCategories(processCategories(catRes.data));
|
||||
const [catRes, opRes, prodData] = await Promise.all([
|
||||
api.get('/categories'),
|
||||
api.get('/operations'),
|
||||
productService.getById(productId)
|
||||
]);
|
||||
const allCategories = processCategories(catRes.data);
|
||||
let filteredCategories = allCategories;
|
||||
|
||||
if (prodData.categoryId) {
|
||||
const rootCategory = allCategories.find(c => c.id === prodData.categoryId);
|
||||
if (rootCategory) {
|
||||
// Filtrar para mostrar solo la categoría raíz y sus descendientes
|
||||
filteredCategories = allCategories.filter(c =>
|
||||
c.id === rootCategory.id || c.path.startsWith(rootCategory.path + ' > ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setFlatCategories(filteredCategories);
|
||||
setOperations(opRes.data);
|
||||
setProduct(prodData);
|
||||
|
||||
// Ajustar días iniciales según la duración del producto
|
||||
if (prodData.priceDurationDays > 1) {
|
||||
setDays(prodData.priceDurationDays);
|
||||
} else {
|
||||
setDays(3);
|
||||
}
|
||||
|
||||
// Si hay una sola opción elegible, seleccionarla automáticamente
|
||||
const selectable = filteredCategories.filter(c => c.isSelectable);
|
||||
if (selectable.length === 1) {
|
||||
setCategoryId(selectable[0].id.toString());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error cargando configuración", e);
|
||||
}
|
||||
@@ -65,13 +100,17 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
|
||||
|
||||
// Calculadora de Precio en Tiempo Real
|
||||
useEffect(() => {
|
||||
if (!categoryId || !text) return;
|
||||
if (!categoryId) {
|
||||
setPricing({ totalPrice: 0, wordCount: 0, details: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
const calculate = async () => {
|
||||
setCalculating(true);
|
||||
try {
|
||||
const res = await api.post('/pricing/calculate', {
|
||||
categoryId: parseInt(categoryId),
|
||||
productId: productId,
|
||||
text: debouncedText,
|
||||
days: days,
|
||||
isBold: styles.isBold,
|
||||
@@ -86,7 +125,7 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
|
||||
}
|
||||
};
|
||||
calculate();
|
||||
}, [debouncedText, categoryId, days, styles, startDate]);
|
||||
}, [debouncedText, categoryId, days, styles, startDate, productId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!categoryId || !operationId || !text) return alert("Complete los campos obligatorios");
|
||||
@@ -258,18 +297,42 @@ export default function AdEditorModal({ isOpen, onClose, onConfirm, clientId }:
|
||||
/>
|
||||
</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>
|
||||
{product?.productTypeId !== 4 && product?.productTypeId !== 6 && (
|
||||
<div className="col-span-1">
|
||||
<label className="text-[10px] font-black text-blue-400 uppercase tracking-widest ml-1 mb-1 block">
|
||||
{product && product.priceDurationDays > 1 ? `Cant. de Módulos (${product.priceDurationDays}d)` : '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(product?.priceDurationDays || 1, days - (product?.priceDurationDays || 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 => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const step = product?.priceDurationDays || 1;
|
||||
// Redondear al múltiplo más cercano si es necesario, o simplemente dejarlo
|
||||
setDays(Math.max(step, val));
|
||||
}}
|
||||
step={product?.priceDurationDays || 1}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setDays(days + (product?.priceDurationDays || 1))}
|
||||
className="px-3 hover:bg-blue-50 text-blue-400 transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{product && product.priceDurationDays > 1 && (
|
||||
<p className="text-[9px] text-blue-400 font-bold mt-1 ml-1 uppercase">Mínimo: {product.priceDurationDays} días</p>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, CheckCircle2, AlertCircle, Settings2, ArrowRight } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { Product } from '../../types/Product';
|
||||
import { productService } from '../../services/productService';
|
||||
import clsx from 'clsx';
|
||||
import AdEditorModal from './AdEditorModal';
|
||||
|
||||
interface Props {
|
||||
bundle: Product;
|
||||
clientId: number;
|
||||
onClose: () => void;
|
||||
onConfirm: (componentsData: ComponentConfig[]) => void;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
productId: number;
|
||||
listingId?: number;
|
||||
description?: string;
|
||||
price?: number;
|
||||
isConfigured: boolean;
|
||||
product: Product;
|
||||
allocationAmount?: number;
|
||||
}
|
||||
|
||||
export default function BundleConfiguratorModal({ bundle, clientId, onClose, onConfirm }: Props) {
|
||||
const [components, setComponents] = useState<ComponentConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Estado para el editor de avisos interno
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
productService.getBundleComponents(bundle.id).then(data => {
|
||||
const configs = data.map(c => ({
|
||||
productId: c.childProductId,
|
||||
isConfigured: !(c.childProduct?.requiresText || c.childProduct?.hasDuration),
|
||||
product: c.childProduct as Product,
|
||||
allocationAmount: c.fixedAllocationAmount || c.childProduct?.basePrice || 0
|
||||
}));
|
||||
setComponents(configs);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [bundle.id]);
|
||||
|
||||
const handleConfigure = (index: number) => {
|
||||
setEditingIndex(index);
|
||||
};
|
||||
|
||||
const handleAdConfirmed = (listingId: number, price: number, description: string) => {
|
||||
if (editingIndex !== null) {
|
||||
const newComponents = [...components];
|
||||
newComponents[editingIndex] = {
|
||||
...newComponents[editingIndex],
|
||||
listingId,
|
||||
price,
|
||||
allocationAmount: price, // Usamos el precio calculado para el prorrateo
|
||||
description,
|
||||
isConfigured: true
|
||||
};
|
||||
setComponents(newComponents);
|
||||
setEditingIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
const allDone = components.every(c => c.isConfigured);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/60 backdrop-blur-md z-[200] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="bg-white w-full max-w-2xl rounded-[2.5rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col max-h-[85vh]"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-8 bg-slate-50 border-b border-slate-100 flex justify-between items-center relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-12 bg-purple-500/5 rounded-full -mr-16 -mt-16"></div>
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="p-2 bg-purple-100 text-purple-600 rounded-xl shadow-sm">
|
||||
<Settings2 size={20} />
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight">Configurar Combo</h3>
|
||||
</div>
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">{bundle.name}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl transition-all text-slate-400 hover:text-slate-600 shadow-sm relative z-10"><X /></button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-8 overflow-y-auto custom-scrollbar flex-1 space-y-4">
|
||||
{loading ? (
|
||||
<div className="py-20 text-center animate-pulse font-black text-slate-300 uppercase tracking-widest">
|
||||
Identificando componentes...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{components.map((c, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
"p-5 rounded-3xl border-2 transition-all flex items-center justify-between group",
|
||||
c.isConfigured
|
||||
? "border-emerald-100 bg-emerald-50/30"
|
||||
: "border-slate-100 bg-white hover:border-blue-200"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm transition-transform group-hover:scale-110",
|
||||
c.isConfigured ? "bg-emerald-500 text-white" : "bg-slate-100 text-slate-400"
|
||||
)}>
|
||||
{c.isConfigured ? <CheckCircle2 size={24} /> : <AlertCircle size={24} />}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-black text-slate-800 leading-tight uppercase tracking-tight">{c.product.name}</h4>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
||||
{c.isConfigured ? '✓ Configuración Completa' : '⚠ Requiere Datos Técnicos'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!c.isConfigured ? (
|
||||
<button
|
||||
onClick={() => handleConfigure(idx)}
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-xl font-black text-[10px] uppercase tracking-widest shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all active:scale-95"
|
||||
>
|
||||
Configurar
|
||||
</button>
|
||||
) : (
|
||||
(c.product.requiresText || c.product.hasDuration) && (
|
||||
<button
|
||||
onClick={() => handleConfigure(idx)}
|
||||
className="text-emerald-600 font-black text-[10px] uppercase tracking-widest hover:underline"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-8 bg-slate-50 border-t border-slate-100 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Progreso</span>
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
{components.map((c, i) => (
|
||||
<div key={i} className={clsx("h-1.5 w-6 rounded-full transition-all duration-500", c.isConfigured ? "bg-emerald-500" : "bg-slate-200")}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!allDone}
|
||||
onClick={() => onConfirm(components)}
|
||||
className={clsx(
|
||||
"px-8 py-4 rounded-2xl font-black text-xs uppercase tracking-[0.15em] transition-all flex items-center gap-3 shadow-xl active:scale-95",
|
||||
allDone
|
||||
? "bg-slate-900 text-white shadow-slate-300 hover:bg-black"
|
||||
: "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
Confirmar Combo <ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Editor Interno para los componentes */}
|
||||
<AnimatePresence>
|
||||
{editingIndex !== null && (
|
||||
<AdEditorModal
|
||||
isOpen={true}
|
||||
onClose={() => setEditingIndex(null)}
|
||||
onConfirm={handleAdConfirmed}
|
||||
clientId={clientId}
|
||||
productId={components[editingIndex].productId}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend/counter-panel/src/components/POS/ClientCreateModal.tsx
Normal file
186
frontend/counter-panel/src/components/POS/ClientCreateModal.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Save, User, MapPin, Mail, Phone, CreditCard, FileText } from 'lucide-react';
|
||||
import CuitInput from '../Shared/CuitInput';
|
||||
import { clientService, type CreateClientRequest } from '../../services/clientService';
|
||||
import { useToast } from '../../context/use-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: (client: { id: number; name: string; dniOrCuit: string }) => void;
|
||||
initialCuit?: string; // Por si el cajero buscó un CUIT y no existía, precargarlo
|
||||
}
|
||||
|
||||
export default function ClientCreateModal({ onClose, onSuccess, initialCuit = '' }: Props) {
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Estado del formulario
|
||||
const [formData, setFormData] = useState<CreateClientRequest>({
|
||||
name: '',
|
||||
dniOrCuit: initialCuit,
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
taxType: 'Consumidor Final'
|
||||
});
|
||||
|
||||
const [isCuitValid, setIsCuitValid] = useState(!!initialCuit);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isCuitValid) return showToast("El CUIT ingresado no es válido", "error");
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const newClient = await clientService.create(formData);
|
||||
showToast("Cliente creado exitosamente", "success");
|
||||
onSuccess(newClient);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
const msg = error.response?.data || "Error al crear cliente";
|
||||
showToast(msg, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[400] 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-200 flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-6 bg-slate-50 border-b border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
|
||||
<User className="text-blue-600" /> Alta Rápida de Cliente
|
||||
</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-8">Registro Express para Facturación</p>
|
||||
</div>
|
||||
<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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* CUIT - Componente Reutilizable */}
|
||||
<div className="md:col-span-1">
|
||||
<CuitInput
|
||||
label="DNI / CUIT"
|
||||
value={formData.dniOrCuit}
|
||||
required
|
||||
onChange={(val, valid) => {
|
||||
setFormData({ ...formData, dniOrCuit: val });
|
||||
setIsCuitValid(valid);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Condición Fiscal */}
|
||||
<div className="md:col-span-1 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} /> Condición Fiscal
|
||||
</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 text-slate-700 appearance-none"
|
||||
value={formData.taxType}
|
||||
onChange={e => setFormData({ ...formData, taxType: e.target.value })}
|
||||
>
|
||||
<option value="Consumidor Final">Consumidor Final</option>
|
||||
<option value="IVA Responsable Inscripto">Responsable Inscripto</option>
|
||||
<option value="Monotributista">Monotributista</option>
|
||||
<option value="IVA Exento">IVA Exento</option>
|
||||
<option value="No Alcanzado">No Alcanzado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Razón Social */}
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1">
|
||||
<CreditCard size={12} /> Razón Social / Nombre Completo <span className="text-rose-500">*</span>
|
||||
</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-black text-slate-800 text-lg uppercase"
|
||||
placeholder="Ej: EMPRESA S.A. o JUAN PEREZ"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dirección */}
|
||||
<div className="md:col-span-2 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 de Facturación
|
||||
</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 text-slate-700"
|
||||
placeholder="Calle, Altura, Localidad"
|
||||
value={formData.address || ''}
|
||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<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">
|
||||
<Mail size={12} /> Correo Electrónico
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
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 text-slate-700"
|
||||
placeholder="contacto@cliente.com"
|
||||
value={formData.email || ''}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Teléfono */}
|
||||
<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">
|
||||
<Phone size={12} /> Teléfono
|
||||
</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 text-slate-700"
|
||||
placeholder="Cod. Area + Número"
|
||||
value={formData.phone || ''}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="pt-4 flex gap-4 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-4 bg-white border-2 border-slate-200 text-slate-500 font-black uppercase text-xs tracking-widest rounded-xl hover:bg-slate-50 transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !isCuitValid || !formData.name}
|
||||
className={clsx(
|
||||
"flex-[2] py-4 rounded-xl font-black uppercase text-xs tracking-widest shadow-lg transition-all flex items-center justify-center gap-2",
|
||||
loading || !isCuitValid || !formData.name
|
||||
? "bg-slate-300 text-white cursor-not-allowed shadow-none"
|
||||
: "bg-blue-600 text-white shadow-blue-200 hover:bg-blue-700"
|
||||
)}
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Registrando...' : 'Crear Cliente'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
frontend/counter-panel/src/components/POS/ClientSearchModal.tsx
Normal file
178
frontend/counter-panel/src/components/POS/ClientSearchModal.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Search, User, Plus, X, ArrowRight, CreditCard, ShieldAlert } from 'lucide-react';
|
||||
import { clientService } from '../../services/clientService';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { motion } from 'framer-motion';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface ClientSearchResult {
|
||||
id: number;
|
||||
name: string;
|
||||
dniOrCuit: string;
|
||||
taxType: string;
|
||||
isCreditBlocked?: boolean; // Si el backend lo devuelve en la búsqueda
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSelect: (client: { id: number; name: string }) => void;
|
||||
onCreateNew: () => void; // Callback para switchear al modal de creación
|
||||
}
|
||||
|
||||
export default function ClientSearchModal({ onClose, onSelect, onCreateNew }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const [results, setResults] = useState<ClientSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Carga inicial y búsqueda
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
clientService.getAll(debouncedQuery)
|
||||
.then((data: any) => {
|
||||
setResults(data);
|
||||
setSelectedIndex(0); // Reset selección al buscar
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [debouncedQuery]);
|
||||
|
||||
// Manejo de Teclado
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev + 1) % results.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev - 1 + results.length) % results.length);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
handleSelect(results[selectedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [results, selectedIndex]);
|
||||
|
||||
const handleSelect = (client: ClientSearchResult) => {
|
||||
onSelect({ id: client.id, name: client.name });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[300] flex items-start justify-center pt-20 p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="bg-white w-full max-w-2xl rounded-[2rem] shadow-2xl overflow-hidden border border-slate-200 flex flex-col max-h-[80vh]"
|
||||
>
|
||||
{/* Header con Buscador */}
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||
<User className="text-blue-600" size={18} /> Buscar Cliente
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Escriba Nombre, Razón Social, DNI o CUIT..."
|
||||
className="w-full pl-12 pr-4 py-4 bg-white border-2 border-slate-200 rounded-xl outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-500/10 font-bold text-lg text-slate-800 placeholder:text-slate-300 transition-all"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Resultados */}
|
||||
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar bg-white min-h-[300px]">
|
||||
{results.length === 0 && !loading ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-400 gap-3">
|
||||
<Search size={48} className="opacity-20" />
|
||||
<p className="text-xs font-bold uppercase tracking-widest">No se encontraron clientes</p>
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="mt-2 bg-blue-600 text-white px-6 py-2 rounded-xl font-black text-xs uppercase tracking-widest hover:bg-blue-700 transition-all flex items-center gap-2"
|
||||
>
|
||||
<Plus size={16} /> Crear Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{results.map((client, idx) => (
|
||||
<div
|
||||
key={client.id}
|
||||
onClick={() => handleSelect(client)}
|
||||
className={clsx(
|
||||
"p-4 rounded-xl cursor-pointer transition-all flex justify-between items-center group",
|
||||
idx === selectedIndex
|
||||
? "bg-blue-600 text-white shadow-md transform scale-[1.01]"
|
||||
: "hover:bg-slate-50 text-slate-700"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm",
|
||||
idx === selectedIndex ? "bg-white/20 text-white" : "bg-slate-100 text-slate-500"
|
||||
)}>
|
||||
{client.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-black uppercase text-sm leading-tight">{client.name}</div>
|
||||
<div className={clsx("text-[10px] font-mono mt-1 flex items-center gap-2", idx === selectedIndex ? "text-blue-100" : "text-slate-400")}>
|
||||
<CreditCard size={10} /> {client.dniOrCuit}
|
||||
<span className="opacity-50">•</span>
|
||||
<span>{client.taxType || 'Consumidor Final'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicador visual si está seleccionado o bloqueado */}
|
||||
<div className="flex items-center gap-3">
|
||||
{client.isCreditBlocked && (
|
||||
<div className={clsx("px-2 py-1 rounded text-[8px] font-black uppercase flex items-center gap-1", idx === selectedIndex ? "bg-rose-500/20 text-white" : "bg-rose-100 text-rose-600")}>
|
||||
<ShieldAlert size={10} /> Bloqueado
|
||||
</div>
|
||||
)}
|
||||
<ArrowRight size={18} className={clsx("transition-transform", idx === selectedIndex ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2")} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer con Ayuda */}
|
||||
<div className="p-3 bg-slate-50 border-t border-slate-100 flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider px-6">
|
||||
<div className="flex gap-4">
|
||||
<span>↑ ↓ Navegar</span>
|
||||
<span>ENTER Seleccionar</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="flex items-center gap-1 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<Plus size={12} /> Crear Nuevo Cliente
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Trash2, Layers } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Product } from '../../types/Product';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
product: Product;
|
||||
onSelect: (product: Product) => void;
|
||||
onRemove: (productId: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function DraggableShortcutCard({ id, product, onSelect, onRemove }: Props) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : 'auto',
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
scale: isDragging ? 1.05 : 1,
|
||||
};
|
||||
|
||||
const getShortcutStyles = (typeCode?: string) => {
|
||||
switch (typeCode) {
|
||||
case 'BUNDLE': return 'from-purple-50 to-white border-purple-100 hover:border-purple-400';
|
||||
case 'CLASSIFIED_AD': return 'from-blue-50 to-white border-blue-100 hover:border-blue-400';
|
||||
default: return 'from-slate-50 to-white border-slate-100 hover:border-slate-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="relative group cursor-grab active:cursor-grabbing h-32"
|
||||
>
|
||||
<motion.button
|
||||
layout
|
||||
onClick={() => onSelect(product)}
|
||||
className={`w-full h-full p-4 rounded-2xl bg-gradient-to-br shadow-sm border-2 transition-all text-left flex flex-col justify-between overflow-hidden relative ${getShortcutStyles(product.typeCode)}`}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => onRemove(product.id, e)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-white/80 backdrop-blur-sm text-red-500 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity z-20 hover:bg-red-500 hover:text-white shadow-sm border border-red-100"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 truncate pr-6">
|
||||
{product.companyName || 'Empresa General'}
|
||||
</span>
|
||||
{product.typeCode === 'BUNDLE' && <Layers size={14} className="text-purple-400 shrink-0" />}
|
||||
</div>
|
||||
<div className="text-sm font-black text-slate-800 line-clamp-2 leading-tight mt-1 group-hover:text-blue-700 transition-colors">
|
||||
{product.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between mt-auto">
|
||||
<div className="flex flex-col">
|
||||
<div className={`text-[9px] font-bold uppercase tracking-tighter ${product.typeCode === 'BUNDLE' ? 'text-purple-500' :
|
||||
product.typeCode === 'CLASSIFIED_AD' ? 'text-blue-500' : 'text-slate-500'
|
||||
}`}>
|
||||
{product.typeCode === 'BUNDLE' ? 'Combo' :
|
||||
product.typeCode === 'CLASSIFIED_AD' ? 'Aviso' : 'Producto'}
|
||||
</div>
|
||||
{product.sku && <div className="text-[8px] font-mono text-slate-300">#{product.sku}</div>}
|
||||
</div>
|
||||
<div className="text-lg font-mono font-black text-slate-900 leading-none">
|
||||
<span className="text-xs font-bold mr-0.5 opacity-50">$</span>
|
||||
{product.basePrice.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sutil brillo al hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white/0 via-white/20 to-white/0 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 pointer-events-none" />
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export default function ProductSearch({ products, onSelect }: Props) {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={20} />
|
||||
<input
|
||||
id="pos-search-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)..."
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Search, Plus } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import type { Product } from '../../types/Product';
|
||||
|
||||
interface Props {
|
||||
catalog: Product[];
|
||||
onClose: () => void;
|
||||
onAdd: (productId: number) => void;
|
||||
existingIds: number[];
|
||||
}
|
||||
|
||||
export default function ShortcutAddModal({ catalog, onClose, onAdd, existingIds }: Props) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Filtrar productos que no estén ya anclados
|
||||
const filtered = catalog.filter(p =>
|
||||
!existingIds.includes(p.id) &&
|
||||
p.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const getProductTypeLabel = (typeCode?: string) => {
|
||||
switch (typeCode) {
|
||||
case 'BUNDLE': return 'Combo / Paquete';
|
||||
case 'CLASSIFIED_AD': return 'Aviso Clasificado';
|
||||
case 'PHYSICAL': return 'Producto Físico';
|
||||
case 'SERVICE': return 'Servicio';
|
||||
case 'GRAPHIC': return 'Publicidad Gráfica';
|
||||
case 'RADIO': return 'Publicidad Radial';
|
||||
default: return 'Producto';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-[250] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[80vh] border border-slate-200"
|
||||
>
|
||||
<div className="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-slate-800 uppercase tracking-tight">Anclar Producto</h3>
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Añadir a accesos rápidos</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white rounded-xl text-slate-400 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6 flex-1 flex flex-col min-h-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
className="w-full pl-12 pr-4 py-4 bg-slate-50 border-2 border-slate-100 rounded-2xl outline-none focus:border-blue-500 font-bold text-sm transition-all"
|
||||
placeholder="Buscar producto o combo..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-2 pr-2">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-sm font-bold italic">No se encontraron productos disponibles</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onAdd(p.id)}
|
||||
className="w-full p-4 rounded-2xl border border-slate-100 hover:border-blue-400 hover:bg-blue-50/30 transition-all flex justify-between items-center group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-black text-slate-700 group-hover:text-blue-700">{p.name}</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
||||
{getProductTypeLabel(p.typeCode)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded-xl text-blue-500 shadow-sm group-hover:bg-blue-600 group-hover:text-white transition-all">
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 text-center">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
Selecciona un producto para anclarlo a tu grilla
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/counter-panel/src/components/Shared/CuitInput.tsx
Normal file
98
frontend/counter-panel/src/components/Shared/CuitInput.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, type ChangeEvent } from 'react';
|
||||
import { isValidCuit, formatCuit } from '../../utils/cuitValidator';
|
||||
import { ShieldCheck, ShieldAlert } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface CuitInputProps {
|
||||
value: string;
|
||||
onChange: (value: string, isValid: boolean) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
label?: string; // Opcional, para renderizar el label dentro del componente
|
||||
}
|
||||
|
||||
export default function CuitInput({
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
placeholder = "20-12345678-9",
|
||||
className,
|
||||
label
|
||||
}: CuitInputProps) {
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isTouched, setIsTouched] = useState(false);
|
||||
|
||||
// Validar al montar o cuando cambia el value externamente
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setIsValid(isValidCuit(value));
|
||||
} else {
|
||||
setIsValid(!required); // Si está vacío y no es requerido, es válido
|
||||
}
|
||||
}, [value, required]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value;
|
||||
const formatted = formatCuit(rawValue);
|
||||
|
||||
// Solo permitir hasta 13 caracteres (11 dígitos + 2 guiones)
|
||||
if (formatted.length > 13) return;
|
||||
|
||||
const valid = isValidCuit(formatted);
|
||||
|
||||
setIsTouched(true);
|
||||
setIsValid(valid);
|
||||
|
||||
// Propagamos el cambio al padre
|
||||
onChange(formatted, valid);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsTouched(true);
|
||||
};
|
||||
|
||||
const showError = isTouched && !isValid && value.length > 0;
|
||||
const showSuccess = isValid && value.length === 13;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-1.5 flex items-center gap-1">
|
||||
{label}
|
||||
{required && <span className="text-rose-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className={clsx(
|
||||
"w-full p-3 bg-slate-50 border-2 rounded-xl outline-none font-mono font-bold text-sm transition-all pr-10",
|
||||
showError
|
||||
? "border-rose-300 text-rose-700 focus:border-rose-500 bg-rose-50"
|
||||
: showSuccess
|
||||
? "border-emerald-300 text-emerald-700 focus:border-emerald-500 bg-emerald-50/30"
|
||||
: "border-slate-100 text-slate-700 focus:border-blue-500"
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Icono de estado absoluto a la derecha */}
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{showError && <ShieldAlert size={18} className="text-rose-500 animate-pulse" />}
|
||||
{showSuccess && <ShieldCheck size={18} className="text-emerald-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showError && (
|
||||
<p className="text-[10px] font-bold text-rose-500 mt-1 ml-1 uppercase tracking-tight">
|
||||
CUIT inválido (Verifique dígito verificador)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { processCategories, type FlatCategory } from '../utils/categoryTreeUtils';
|
||||
import { productService } from '../services/productService';
|
||||
import type { Product } from '../types/Product';
|
||||
import {
|
||||
Printer, Save,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
X,
|
||||
UploadCloud,
|
||||
MessageSquare,
|
||||
Star
|
||||
Star,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import PaymentModal, { type Payment } from '../components/PaymentModal';
|
||||
@@ -53,8 +56,13 @@ export default function FastEntryPage() {
|
||||
const [isCatDropdownOpen, setIsCatDropdownOpen] = useState(false);
|
||||
const catWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Estado del selector de productos por rubro
|
||||
const [categoryProducts, setCategoryProducts] = useState<Product[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
const [loadingProducts, setLoadingProducts] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '',
|
||||
categoryId: '', operationId: '', text: '', title: '', price: '', days: 3, clientName: '', clientDni: '', clientId: null as number | null,
|
||||
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
||||
isFeatured: false, allowContact: false
|
||||
});
|
||||
@@ -212,6 +220,33 @@ export default function FastEntryPage() {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Cargar productos cuando cambia el rubro seleccionado
|
||||
useEffect(() => {
|
||||
if (!formData.categoryId) {
|
||||
setCategoryProducts([]);
|
||||
setSelectedProduct(null);
|
||||
return;
|
||||
}
|
||||
setLoadingProducts(true);
|
||||
productService.getByCategory(parseInt(formData.categoryId))
|
||||
.then(prods => {
|
||||
setCategoryProducts(prods);
|
||||
if (prods.length > 0) setSelectedProduct(prods[0]);
|
||||
else setSelectedProduct(null);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingProducts(false));
|
||||
}, [formData.categoryId]);
|
||||
|
||||
// Ajustar días automáticamente al seleccionar un producto con duración especial
|
||||
useEffect(() => {
|
||||
if (selectedProduct && selectedProduct.priceDurationDays > 1) {
|
||||
if (formData.days % selectedProduct.priceDurationDays !== 0 || formData.days < selectedProduct.priceDurationDays) {
|
||||
setFormData(prev => ({ ...prev, days: selectedProduct.priceDurationDays }));
|
||||
}
|
||||
}
|
||||
}, [selectedProduct]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validate()) return;
|
||||
setShowPaymentModal(true);
|
||||
@@ -240,6 +275,7 @@ export default function FastEntryPage() {
|
||||
try {
|
||||
const res = await api.post('/pricing/calculate', {
|
||||
categoryId: parseInt(formData.categoryId),
|
||||
productId: selectedProduct?.id || 0,
|
||||
text: debouncedText || "",
|
||||
days: formData.days,
|
||||
isBold: options.isBold,
|
||||
@@ -250,7 +286,7 @@ export default function FastEntryPage() {
|
||||
} catch (error) { console.error(error); }
|
||||
};
|
||||
calculatePrice();
|
||||
}, [debouncedText, formData.categoryId, formData.days, options, formData.startDate]);
|
||||
}, [debouncedText, formData.categoryId, selectedProduct, formData.days, options, formData.startDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedClientSearch.length > 2 && showSuggestions) {
|
||||
@@ -260,7 +296,7 @@ export default function FastEntryPage() {
|
||||
}
|
||||
}, [debouncedClientSearch, showSuggestions]);
|
||||
|
||||
const handlePaymentConfirm = async (payments: Payment[]) => {
|
||||
const handlePaymentConfirm = async (payments: Payment[], _isCreditSale: boolean) => {
|
||||
try {
|
||||
const listingRes = await api.post('/listings', {
|
||||
categoryId: parseInt(formData.categoryId),
|
||||
@@ -308,6 +344,7 @@ export default function FastEntryPage() {
|
||||
price: '',
|
||||
clientName: '',
|
||||
clientDni: '',
|
||||
clientId: null,
|
||||
startDate: new Date(Date.now() + 86400000).toISOString().split('T')[0],
|
||||
isFeatured: false,
|
||||
allowContact: false
|
||||
@@ -315,6 +352,7 @@ export default function FastEntryPage() {
|
||||
setOptions({ isBold: false, isFrame: false, fontSize: 'normal', alignment: 'left' });
|
||||
setSelectedImages([]);
|
||||
setImagePreviews([]);
|
||||
setSelectedProduct(null);
|
||||
setShowPaymentModal(false);
|
||||
setErrors({});
|
||||
showToast('Aviso procesado correctamente.', 'success');
|
||||
@@ -324,7 +362,7 @@ export default function FastEntryPage() {
|
||||
};
|
||||
|
||||
const handleSelectClient = (client: Client) => {
|
||||
setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit }));
|
||||
setFormData(prev => ({ ...prev, clientName: client.name, clientDni: client.dniOrCuit, clientId: client.id }));
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
@@ -434,6 +472,50 @@ export default function FastEntryPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SELECTOR DE PRODUCTO */}
|
||||
{formData.categoryId && (
|
||||
<div>
|
||||
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest flex items-center gap-2">
|
||||
<Package size={12} /> Producto / Tarifa
|
||||
</label>
|
||||
{loadingProducts ? (
|
||||
<div className="py-2 px-4 border-2 border-slate-100 rounded-xl bg-slate-50 text-slate-400 text-xs font-bold animate-pulse">
|
||||
Cargando productos del rubro...
|
||||
</div>
|
||||
) : categoryProducts.length === 0 ? (
|
||||
<div className="py-2 px-4 border-2 border-amber-100 rounded-xl bg-amber-50 text-amber-600 text-xs font-bold flex items-center gap-2">
|
||||
⚠️ Sin productos en este rubro — el precio base será $0
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${Math.min(categoryProducts.length, 3)}, 1fr)` }}>
|
||||
{categoryProducts.map(prod => (
|
||||
<button
|
||||
key={prod.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedProduct(prod)}
|
||||
className={clsx(
|
||||
"py-2 px-3 border-2 rounded-xl text-left transition-all duration-200 group",
|
||||
selectedProduct?.id === prod.id
|
||||
? "border-blue-500 bg-blue-600 text-white shadow-lg shadow-blue-200"
|
||||
: "border-slate-100 bg-slate-50 hover:border-blue-300 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("text-[10px] font-black uppercase tracking-tighter truncate", selectedProduct?.id === prod.id ? "text-blue-100" : "text-slate-500")}>
|
||||
{prod.typeCode}
|
||||
</div>
|
||||
<div className={clsx("text-xs font-black truncate leading-tight mt-0.5", selectedProduct?.id === prod.id ? "text-white" : "text-slate-800")}>
|
||||
{prod.name}
|
||||
</div>
|
||||
<div className={clsx("text-sm font-mono font-black mt-1", selectedProduct?.id === prod.id ? "text-green-300" : "text-blue-600")}>
|
||||
${prod.basePrice.toLocaleString()}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-8">
|
||||
<label className="block text-[10px] font-black text-slate-500 uppercase mb-1.5 tracking-widest">Título Web (Opcional)</label>
|
||||
@@ -492,14 +574,38 @@ export default function FastEntryPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Días</label>
|
||||
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
|
||||
<button onClick={() => setFormData(f => ({ ...f, days: Math.max(1, f.days - 1) }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">-</button>
|
||||
<input type="number" className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm" value={formData.days} onChange={e => setFormData({ ...formData, days: Math.max(1, parseInt(e.target.value) || 0) })} />
|
||||
<button onClick={() => setFormData(f => ({ ...f, days: f.days + 1 }))} className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors">+</button>
|
||||
{selectedProduct?.productTypeId !== 4 && selectedProduct?.productTypeId !== 6 && (
|
||||
<div className="col-span-2">
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">
|
||||
{selectedProduct && selectedProduct.priceDurationDays > 1 ? `Días (Base ${selectedProduct.priceDurationDays})` : 'Días'}
|
||||
</label>
|
||||
<div className="flex items-center bg-slate-50 rounded-lg border border-slate-200 overflow-hidden h-9">
|
||||
<button
|
||||
onClick={() => setFormData(f => ({ ...f, days: Math.max(selectedProduct?.priceDurationDays || 1, f.days - (selectedProduct?.priceDurationDays || 1)) }))}
|
||||
className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full text-center font-black text-blue-600 outline-none bg-transparent text-sm"
|
||||
value={formData.days}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
const step = selectedProduct?.priceDurationDays || 1;
|
||||
setFormData({ ...formData, days: Math.max(step, val) });
|
||||
}}
|
||||
step={selectedProduct?.priceDurationDays || 1}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormData(f => ({ ...f, days: f.days + (selectedProduct?.priceDurationDays || 1) }))}
|
||||
className="px-2.5 hover:bg-slate-50 text-slate-400 font-bold transition-colors"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-5 relative" ref={clientWrapperRef}>
|
||||
<label className="block text-[9px] font-black text-slate-500 uppercase mb-1 tracking-widest">Cliente / Razón Social</label>
|
||||
<div className="relative h-9">
|
||||
@@ -560,6 +666,12 @@ export default function FastEntryPage() {
|
||||
<div className="text-4xl font-mono font-black text-green-400 flex items-start gap-1">
|
||||
<span className="text-lg mt-1 opacity-50">$</span>{pricing.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
{selectedProduct && (
|
||||
<div className="mt-2 flex items-center gap-2 py-1.5 px-2 bg-blue-600/20 rounded-lg border border-blue-500/30">
|
||||
<Package size={10} className="text-blue-400 shrink-0" />
|
||||
<span className="text-[9px] font-black text-blue-300 truncate uppercase tracking-tight">{selectedProduct.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 pt-3 border-t border-slate-800 space-y-1.5 text-[10px] font-bold uppercase tracking-tighter">
|
||||
<div className="flex justify-between text-slate-500 italic"><span>Tarifa Base</span><span className="text-slate-300">${pricing.baseCost.toLocaleString()}</span></div>
|
||||
{pricing.extraCost > 0 && <div className="flex justify-between text-orange-500"><span>Recargos Texto</span><span>+${pricing.extraCost.toLocaleString()}</span></div>}
|
||||
@@ -649,7 +761,7 @@ export default function FastEntryPage() {
|
||||
</motion.div>
|
||||
|
||||
{showPaymentModal && (
|
||||
<PaymentModal totalAmount={pricing.totalPrice} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
|
||||
<PaymentModal totalAmount={pricing.totalPrice} clientId={formData.clientId} onConfirm={handlePaymentConfirm} onCancel={() => setShowPaymentModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,32 +3,83 @@ 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 { Trash2, ShoppingCart, CreditCard, User, Box, Layers, PlusCircle } 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';
|
||||
import BundleConfiguratorModal, { type ComponentConfig } from '../components/POS/BundleConfiguratorModal';
|
||||
import ClientCreateModal from '../components/POS/ClientCreateModal';
|
||||
import ClientSearchModal from '../components/POS/ClientSearchModal';
|
||||
import ShortcutAddModal from '../components/POS/ShortcutAddModal';
|
||||
import { DraggableShortcutCard } from '../components/POS/DraggableShortcutCard';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
// DND Kit
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
rectSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
|
||||
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 [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
const [loadingShortcuts, setLoadingShortcuts] = useState(true);
|
||||
const [showShortcutAdd, setShowShortcutAdd] = useState(false);
|
||||
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);
|
||||
const [showBundleConfigurator, setShowBundleConfigurator] = useState(false);
|
||||
const [selectedBundle, setSelectedBundle] = useState<Product | null>(null);
|
||||
const [showCreateClient, setShowCreateClient] = useState(false);
|
||||
const [showClientSearch, setShowClientSearch] = useState(false);
|
||||
|
||||
// Estado de carga para agregar combos (puede tardar un poco en traer los hijos)
|
||||
const [addingProduct, setAddingProduct] = useState(false);
|
||||
|
||||
const fetchShortcuts = async () => {
|
||||
setLoadingShortcuts(true);
|
||||
try {
|
||||
const data = await productService.getShortcuts();
|
||||
setShortcuts(data || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setShortcuts([]);
|
||||
} finally {
|
||||
setLoadingShortcuts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }, // Evita disparar drag al simplemente hacer click
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
productService.getAll().then(setCatalog).catch(console.error);
|
||||
fetchShortcuts();
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
@@ -46,27 +97,28 @@ export default function UniversalPosPage() {
|
||||
e.preventDefault();
|
||||
handleCheckout();
|
||||
}
|
||||
// F7: Cambiar Cliente (Antes F9)
|
||||
// F7: Cambiar Cliente
|
||||
if (e.key === 'F7') {
|
||||
e.preventDefault();
|
||||
handleChangeClient();
|
||||
}
|
||||
// F3: Buscar Producto (Buscador Global)
|
||||
if (e.key === 'F3') {
|
||||
e.preventDefault();
|
||||
document.getElementById('pos-search-input')?.focus();
|
||||
}
|
||||
// F6: Accesos Rápidos
|
||||
if (e.key === 'F6') {
|
||||
e.preventDefault();
|
||||
// Foco en el primer botón de los accesos
|
||||
const firstShortcut = document.querySelector('.shortcut-card button');
|
||||
(firstShortcut as HTMLElement)?.focus();
|
||||
}
|
||||
};
|
||||
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 {
|
||||
@@ -78,16 +130,11 @@ export default function UniversalPosPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización
|
||||
// 2. COMBOS (BUNDLES) - Lógica de Visualización y Configuració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');
|
||||
if (!clientId) setClient(1005, "Consumidor Final (Default)");
|
||||
setSelectedBundle(product);
|
||||
setShowBundleConfigurator(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,12 +163,94 @@ export default function UniversalPosPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBundleConfirmed = (configs: ComponentConfig[]) => {
|
||||
if (selectedBundle) {
|
||||
const subItemsNames = configs.map(c =>
|
||||
`${c.product.name} ${c.isConfigured ? '✓' : ''}`
|
||||
);
|
||||
|
||||
addItem(selectedBundle, 1, {
|
||||
subItems: subItemsNames,
|
||||
componentsData: configs
|
||||
});
|
||||
|
||||
showToast(`${selectedBundle.name} configurado y agregado`, 'success');
|
||||
setShowBundleConfigurator(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (productId: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Evitar disparar la selección de producto
|
||||
try {
|
||||
await productService.removeShortcut(productId);
|
||||
showToast('Acceso rápido quitado', 'success');
|
||||
fetchShortcuts();
|
||||
} catch (error) {
|
||||
showToast('Error al quitar acceso rápido', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (productId: number) => {
|
||||
try {
|
||||
await productService.addShortcut(productId);
|
||||
showToast('Acceso rápido agregado', 'success');
|
||||
fetchShortcuts();
|
||||
setShowShortcutAdd(false);
|
||||
} catch (error) {
|
||||
showToast('Error al agregar acceso rápido', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setShortcuts((items) => {
|
||||
const oldIndex = items.findIndex(i => i.productId === active.id);
|
||||
const newIndex = items.findIndex(i => i.productId === over.id);
|
||||
const newOrder = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Persistir en backend
|
||||
productService.reorderShortcuts(newOrder.map(s => s.productId))
|
||||
.catch(e => {
|
||||
console.error("Error persistiendo orden", e);
|
||||
showToast("No se pudo guardar el orden", "error");
|
||||
fetchShortcuts(); // Rollback local
|
||||
});
|
||||
|
||||
return newOrder;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (items.length === 0) return showToast("El carrito está vacío", "error");
|
||||
if (!clientId) setClient(1005, "Consumidor Final");
|
||||
setShowPayment(true);
|
||||
};
|
||||
|
||||
const handleChangeClient = () => {
|
||||
setShowClientSearch(true);
|
||||
};
|
||||
|
||||
// Callback cuando seleccionan del buscador
|
||||
const handleClientSelected = (client: { id: number; name: string }) => {
|
||||
setClient(client.id, client.name);
|
||||
// showClientSearch se cierra automáticamente por el componente, o lo forzamos aquí si es necesario
|
||||
// El componente ClientSearchModal llama a onClose internamente después de onSelect
|
||||
};
|
||||
|
||||
// Callback cuando crean uno nuevo
|
||||
const handleClientCreated = (client: { id: number; name: string }) => {
|
||||
setClient(client.id, client.name);
|
||||
setShowCreateClient(false);
|
||||
};
|
||||
|
||||
// Función puente: Del Buscador -> Al Creador
|
||||
const switchToCreate = () => {
|
||||
setShowClientSearch(false);
|
||||
setTimeout(() => setShowCreateClient(true), 100); // Pequeño delay para transición suave
|
||||
};
|
||||
|
||||
const finalizeOrder = async (_payments: Payment[], isCreditSale: boolean) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
@@ -131,12 +260,31 @@ export default function UniversalPosPage() {
|
||||
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
|
||||
}))
|
||||
items: items.flatMap(i => {
|
||||
if (i.componentsData && i.componentsData.length > 0) {
|
||||
// Expandir el combo en sus partes para el backend
|
||||
// El precio se prorratea según el total del combo
|
||||
const bundleTotal = i.subTotal;
|
||||
const sumAllocations = i.componentsData.reduce((acc, c) => acc + (c.allocationAmount || 0), 0);
|
||||
const ratio = sumAllocations > 0 ? (bundleTotal / sumAllocations) : 1;
|
||||
|
||||
return i.componentsData.map(c => ({
|
||||
productId: c.productId,
|
||||
quantity: 1,
|
||||
unitPrice: (c.allocationAmount || 0) * ratio,
|
||||
relatedEntityId: c.listingId,
|
||||
relatedEntityType: c.listingId ? 'Listing' : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
productId: i.productId,
|
||||
quantity: i.quantity,
|
||||
unitPrice: i.unitPrice,
|
||||
relatedEntityId: i.relatedEntityId,
|
||||
relatedEntityType: i.relatedEntityType
|
||||
}];
|
||||
})
|
||||
};
|
||||
|
||||
const result = await orderService.createOrder(payload);
|
||||
@@ -164,29 +312,55 @@ export default function UniversalPosPage() {
|
||||
</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>
|
||||
<h3 className="text-xs font-black text-slate-400 uppercase tracking-widest mb-4 flex justify-between items-center">
|
||||
Accesos Rápidos
|
||||
<span className="text-[10px] lowercase font-bold">{shortcuts.length} / 9</span>
|
||||
</h3>
|
||||
|
||||
{loadingShortcuts ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||
<div className="w-8 h-8 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Cargando accesos...</span>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SortableContext
|
||||
items={shortcuts.map(s => s.productId)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{shortcuts.map(s => (
|
||||
<div key={s.productId} className="shortcut-card">
|
||||
<DraggableShortcutCard
|
||||
id={s.productId}
|
||||
product={s.product}
|
||||
onSelect={handleProductSelect}
|
||||
onRemove={handleRemoveShortcut}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{shortcuts.length < 9 && (
|
||||
<button
|
||||
onClick={() => setShowShortcutAdd(true)}
|
||||
className="bg-white/50 p-4 rounded-2xl border-2 border-dashed border-slate-300 text-slate-400 hover:border-blue-400 hover:text-blue-500 hover:bg-white transition-all flex flex-col items-center justify-center gap-2 h-32 group"
|
||||
>
|
||||
<div className="p-2 bg-slate-100 rounded-full group-hover:bg-blue-50 transition-colors">
|
||||
<PlusCircle size={24} />
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Anclar Acceso</span>
|
||||
</button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,8 +469,51 @@ export default function UniversalPosPage() {
|
||||
onClose={() => setShowAdEditor(false)}
|
||||
onConfirm={handleAdConfirmed}
|
||||
clientId={clientId || 1005}
|
||||
productId={selectedAdProduct?.id || 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBundleConfigurator && selectedBundle && (
|
||||
<BundleConfiguratorModal
|
||||
bundle={selectedBundle}
|
||||
clientId={clientId || 1005}
|
||||
onClose={() => setShowBundleConfigurator(false)}
|
||||
onConfirm={handleBundleConfirmed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MODAL DE BÚSQUEDA DE CLIENTE (F7) */}
|
||||
<AnimatePresence>
|
||||
{showClientSearch && (
|
||||
<ClientSearchModal
|
||||
onClose={() => setShowClientSearch(false)}
|
||||
onSelect={handleClientSelected}
|
||||
onCreateNew={switchToCreate}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* MODAL DE ALTA RÁPIDA */}
|
||||
<AnimatePresence>
|
||||
{showCreateClient && (
|
||||
<ClientCreateModal
|
||||
onClose={() => setShowCreateClient(false)}
|
||||
onSuccess={handleClientCreated}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* MODAL DE AGREGAR ACCESO RÁPIDO */}
|
||||
<AnimatePresence>
|
||||
{showShortcutAdd && (
|
||||
<ShortcutAddModal
|
||||
catalog={catalog}
|
||||
existingIds={shortcuts.map(s => s.productId)}
|
||||
onClose={() => setShowShortcutAdd(false)}
|
||||
onAdd={handleAddShortcut}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/counter-panel/src/services/clientService.ts
Normal file
33
frontend/counter-panel/src/services/clientService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/services/clientService.ts
|
||||
import api from './api';
|
||||
|
||||
export interface CreateClientRequest {
|
||||
name: string;
|
||||
dniOrCuit: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
taxType: string;
|
||||
}
|
||||
|
||||
export const clientService = {
|
||||
getAll: async (q?: string) => {
|
||||
const res = await api.get('/clients', { params: { q } });
|
||||
return res.data;
|
||||
},
|
||||
getSummary: async (id: number) => {
|
||||
const res = await api.get(`/clients/${id}/summary`);
|
||||
return res.data;
|
||||
},
|
||||
update: async (id: number, clientData: any) => {
|
||||
await api.put(`/clients/${id}`, clientData);
|
||||
},
|
||||
resetPassword: async (id: number) => {
|
||||
const res = await api.post(`/clients/${id}/reset-password`);
|
||||
return res.data;
|
||||
},
|
||||
create: async (data: CreateClientRequest) => {
|
||||
const res = await api.post('/clients', data);
|
||||
return res.data; // Retorna { id, name, dniOrCuit }
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,12 @@ export const productService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Obtiene los productos clasificados vinculados a un rubro
|
||||
getByCategory: async (categoryId: number): Promise<Product[]> => {
|
||||
const response = await api.get<Product[]>(`/products/by-category/${categoryId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (product: Partial<Product>): Promise<Product> => {
|
||||
const response = await api.post<Product>('/products', product);
|
||||
return response.data;
|
||||
@@ -21,6 +27,10 @@ export const productService = {
|
||||
await api.put(`/products/${id}`, product);
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await api.delete(`/products/${id}`);
|
||||
},
|
||||
|
||||
// --- GESTIÓN DE COMBOS (BUNDLES) ---
|
||||
|
||||
/**
|
||||
@@ -45,5 +55,24 @@ export const productService = {
|
||||
getBundleComponents: async (bundleId: number): Promise<ProductBundleComponent[]> => {
|
||||
const response = await api.get<ProductBundleComponent[]>(`/products/${bundleId}/components`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
|
||||
getShortcuts: async (): Promise<any[]> => {
|
||||
const response = await api.get('/products/shortcuts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addShortcut: async (productId: number): Promise<void> => {
|
||||
await api.post(`/products/shortcuts/${productId}`);
|
||||
},
|
||||
|
||||
removeShortcut: async (productId: number): Promise<void> => {
|
||||
await api.delete(`/products/shortcuts/${productId}`);
|
||||
},
|
||||
|
||||
reorderShortcuts: async (productIds: number[]): Promise<void> => {
|
||||
await api.patch('/products/shortcuts/order', productIds);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import type { OrderItemDto } from '../types/Order';
|
||||
import type { Product } from '../types/Product';
|
||||
import type { ComponentConfig } from '../components/POS/BundleConfiguratorModal';
|
||||
|
||||
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[];
|
||||
// Datos de configuración de cada componente del combo
|
||||
componentsData?: ComponentConfig[];
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
@@ -22,7 +24,8 @@ interface CartState {
|
||||
quantity: number,
|
||||
options?: {
|
||||
relatedEntity?: { id: number, type: string, extraInfo?: string },
|
||||
subItems?: string[]
|
||||
subItems?: string[],
|
||||
componentsData?: ComponentConfig[]
|
||||
}
|
||||
) => void;
|
||||
|
||||
@@ -41,10 +44,10 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
|
||||
addItem: (product, quantity, options) => {
|
||||
const currentItems = get().items;
|
||||
const { relatedEntity, subItems } = options || {};
|
||||
const { relatedEntity, subItems, componentsData } = options || {};
|
||||
|
||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo), no agrupamos
|
||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0);
|
||||
// Si tiene entidad relacionada (Aviso) o SubItems (Combo) o Configuración interna, no agrupamos
|
||||
const isComplexItem = !!relatedEntity || (subItems && subItems.length > 0) || (componentsData && componentsData.length > 0);
|
||||
|
||||
const existingIndex = !isComplexItem
|
||||
? currentItems.findIndex(i => i.productId === product.id && !i.relatedEntityId)
|
||||
@@ -67,7 +70,8 @@ export const useCartStore = create<CartState>((set, get) => ({
|
||||
subTotal: product.basePrice * quantity,
|
||||
relatedEntityId: relatedEntity?.id,
|
||||
relatedEntityType: relatedEntity?.type,
|
||||
subItems: subItems // Guardamos la lista visual
|
||||
subItems: subItems,
|
||||
componentsData: componentsData
|
||||
};
|
||||
set({ items: [...currentItems, newItem] });
|
||||
}
|
||||
|
||||
@@ -2,18 +2,23 @@ export interface Product {
|
||||
id: number;
|
||||
companyId: number;
|
||||
productTypeId: number; // 1:Classified, 2:Graphic, 3:Radio, 4:Physical, 5:Service, 6:Bundle
|
||||
categoryId?: number; // Para relacionarlo a un rubro
|
||||
name: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
externalId?: string;
|
||||
basePrice: number;
|
||||
priceDurationDays: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
|
||||
// Campos extendidos para UI (Joins)
|
||||
companyName?: string;
|
||||
// Propiedades de Tipo de Producto (Joins)
|
||||
requiresText: boolean;
|
||||
hasDuration: boolean;
|
||||
requiresCategory: boolean;
|
||||
typeCode?: string; // 'BUNDLE', 'PHYSICAL', etc.
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export interface ProductBundleComponent {
|
||||
|
||||
43
frontend/counter-panel/src/utils/cuitValidator.ts
Normal file
43
frontend/counter-panel/src/utils/cuitValidator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Valida un CUIT/CUIL argentino aplicando el algoritmo de Módulo 11.
|
||||
* Soporta formatos con o sin guiones (20-12345678-9 o 20123456789).
|
||||
*/
|
||||
export const isValidCuit = (cuit: string): boolean => {
|
||||
if (!cuit) return false;
|
||||
|
||||
// 1. Limpiar el input: dejar solo números
|
||||
const cleanCuit = cuit.replace(/[^0-9]/g, '');
|
||||
|
||||
// 2. Verificar longitud exacta (11 dígitos)
|
||||
if (cleanCuit.length !== 11) return false;
|
||||
|
||||
// 3. Convertir a array de números
|
||||
const digits = cleanCuit.split('').map(Number);
|
||||
|
||||
// 4. Algoritmo de verificación (Módulo 11)
|
||||
const multipliers = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];
|
||||
|
||||
let total = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
total += digits[i] * multipliers[i];
|
||||
}
|
||||
|
||||
const remainder = total % 11;
|
||||
const calculatedVerifier = remainder === 0 ? 0 : remainder === 1 ? 9 : 11 - remainder;
|
||||
const actualVerifier = digits[10];
|
||||
|
||||
// Caso especial: Cuando el resto es 1, el resultado suele ser 9 en algunos casos específicos
|
||||
// o se considera inválido y se debe cambiar el prefijo (Hombres/Mujeres),
|
||||
// pero para validación estricta matemática:
|
||||
return calculatedVerifier === actualVerifier;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatea un string de números al formato XX-XXXXXXXX-X
|
||||
*/
|
||||
export const formatCuit = (cuit: string): string => {
|
||||
const nums = cuit.replace(/[^0-9]/g, '');
|
||||
if (nums.length <= 2) return nums;
|
||||
if (nums.length <= 10) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
|
||||
return `${nums.slice(0, 2)}-${nums.slice(2, 10)}-${nums.slice(10, 11)}`;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SIGCM.Application.DTOs;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Infrastructure.Repositories;
|
||||
|
||||
@@ -90,4 +91,51 @@ public class ClientsController : ControllerBase
|
||||
|
||||
return Ok(new { message = "Contraseña restablecida a '1234' correctamente." });
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateClientDto dto)
|
||||
{
|
||||
// 1. Validar si ya existe (por CUIT)
|
||||
// Usamos el repo de usuarios porque los clientes son usuarios
|
||||
var existing = await _repo.SearchAsync(dto.DniOrCuit);
|
||||
if (existing.Any(c => c.DniOrCuit == dto.DniOrCuit))
|
||||
{
|
||||
return BadRequest("Ya existe un cliente registrado con este DNI/CUIT.");
|
||||
}
|
||||
|
||||
// 2. Mapeo a Entidad User (Modelo de DB)
|
||||
// Nota: El repositorio ClientRepository maneja la inserción en la tabla Users
|
||||
// Vamos a crear un método específico en el repo para esto o usar el existente mejorado.
|
||||
|
||||
var client = new Client // Usamos la entidad de dominio Client como DTO de transporte al repo
|
||||
{
|
||||
Name = dto.Name,
|
||||
DniOrCuit = dto.DniOrCuit,
|
||||
Email = dto.Email,
|
||||
Phone = dto.Phone,
|
||||
Address = dto.Address,
|
||||
TaxType = dto.TaxType,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
// Llamamos a un nuevo método en el repo para creación completa
|
||||
var id = await _repo.CreateFullClientAsync(client);
|
||||
|
||||
// Audit Log
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (int.TryParse(userIdClaim, out int userId))
|
||||
{
|
||||
await _auditRepo.AddLogAsync(new AuditLog
|
||||
{
|
||||
UserId = userId,
|
||||
Action = "CREATE_CLIENT",
|
||||
EntityId = id,
|
||||
EntityType = "Client",
|
||||
Details = $"Alta Rápida de Cliente: {dto.Name} ({dto.DniOrCuit})",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { id, name = dto.Name, dniOrCuit = dto.DniOrCuit });
|
||||
}
|
||||
}
|
||||
64
src/SIGCM.API/Controllers/ProductTypesController.cs
Normal file
64
src/SIGCM.API/Controllers/ProductTypesController.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
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")]
|
||||
public class ProductTypesController : ControllerBase
|
||||
{
|
||||
private readonly IProductTypeRepository _repo;
|
||||
|
||||
public ProductTypesController(IProductTypeRepository repo)
|
||||
{
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous] // Permitir que el frontal cargue los tipos para formularios
|
||||
public async Task<IActionResult> GetAll()
|
||||
{
|
||||
var types = await _repo.GetAllAsync();
|
||||
return Ok(types);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var type = await _repo.GetByIdAsync(id);
|
||||
if (type == null) return NotFound();
|
||||
return Ok(type);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(ProductType productType)
|
||||
{
|
||||
var id = await _repo.CreateAsync(productType);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, productType);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Update(int id, ProductType productType)
|
||||
{
|
||||
if (id != productType.Id) return BadRequest();
|
||||
await _repo.UpdateAsync(productType);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repo.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,14 @@ public class ProductsController : ControllerBase
|
||||
return Ok(products);
|
||||
}
|
||||
|
||||
// Obtener productos clasificados vinculados a un rubro específico
|
||||
[HttpGet("by-category/{categoryId}")]
|
||||
public async Task<IActionResult> GetByCategory(int categoryId)
|
||||
{
|
||||
var products = await _repository.GetByCategoryIdAsync(categoryId);
|
||||
return Ok(products);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin")] // Solo Admins crean productos
|
||||
public async Task<IActionResult> Create(Product product)
|
||||
@@ -62,6 +70,21 @@ public class ProductsController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Obtener lista de empresas para llenar el combo en el Frontend
|
||||
[HttpGet("companies")]
|
||||
public async Task<IActionResult> GetCompanies()
|
||||
@@ -118,4 +141,46 @@ public class ProductsController : ControllerBase
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
|
||||
[HttpGet("shortcuts")]
|
||||
public async Task<IActionResult> GetShortcuts()
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
var shortcuts = await _repository.GetUserShortcutsAsync(userId);
|
||||
return Ok(shortcuts);
|
||||
}
|
||||
|
||||
[HttpPost("shortcuts/{productId}")]
|
||||
public async Task<IActionResult> AddShortcut(int productId)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.AddShortcutAsync(userId, productId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("shortcuts/{productId}")]
|
||||
public async Task<IActionResult> RemoveShortcut(int productId)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.RemoveShortcutAsync(userId, productId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPatch("shortcuts/order")]
|
||||
public async Task<IActionResult> UpdateShortcutsOrder([FromBody] List<int> productIds)
|
||||
{
|
||||
var userIdClaim = User.FindFirst("Id")?.Value;
|
||||
if (!int.TryParse(userIdClaim, out int userId)) return Unauthorized();
|
||||
|
||||
await _repository.UpdateShortcutsOrderAsync(userId, productIds);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
8
src/SIGCM.API/appsettings.Development.json
Normal file
8
src/SIGCM.API/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/SIGCM.Application/DTOs/ClientDtos.cs
Normal file
11
src/SIGCM.Application/DTOs/ClientDtos.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SIGCM.Application.DTOs;
|
||||
|
||||
public class CreateClientDto
|
||||
{
|
||||
public required string Name { get; set; } // Razón Social o Nombre
|
||||
public required string DniOrCuit { get; set; } // Validado por frontend
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string TaxType { get; set; } = "Consumidor Final";
|
||||
}
|
||||
@@ -19,9 +19,8 @@ public class OrderItemDto
|
||||
public int ProductId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
// Opcional: Si el vendedor aplica un descuento manual unitario
|
||||
// (Por ahora usaremos el precio base del producto)
|
||||
// public decimal? ManualUnitPrice { get; set; }
|
||||
// Opcional: Precio unitario manual (se usa en Combos prorrateados o descuentos)
|
||||
public decimal? UnitPrice { get; set; }
|
||||
|
||||
// Para vincular con un aviso específico creado previamente
|
||||
public int? RelatedEntityId { get; set; }
|
||||
|
||||
@@ -3,6 +3,7 @@ namespace SIGCM.Application.DTOs;
|
||||
public class CalculatePriceRequest
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public int ProductId { get; set; } // Añadido para obtener el Precio Base
|
||||
public required string Text { get; set; }
|
||||
public int Days { get; set; }
|
||||
public bool IsBold { get; set; }
|
||||
|
||||
@@ -4,11 +4,12 @@ public class CategoryPricing
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public decimal BasePrice { get; set; }
|
||||
public int BaseWordCount { get; set; }
|
||||
public decimal ExtraWordPrice { get; set; }
|
||||
public string SpecialChars { get; set; } = "!";
|
||||
public decimal SpecialCharPrice { get; set; }
|
||||
public decimal BoldSurcharge { get; set; }
|
||||
public decimal FrameSurcharge { get; set; }
|
||||
|
||||
public List<WordPricingRange> WordRanges { get; set; } = new();
|
||||
}
|
||||
@@ -5,12 +5,14 @@ public class Product
|
||||
public int Id { get; set; }
|
||||
public int CompanyId { get; set; }
|
||||
public int ProductTypeId { get; set; }
|
||||
public int? CategoryId { get; set; } // Propiedad agregada: Vínculo con Rubro
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? SKU { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
public decimal BasePrice { get; set; }
|
||||
public int PriceDurationDays { get; set; } = 1; // Días que cubre el precio base (ej: 30 para autos)
|
||||
public decimal TaxRate { get; set; } // 21.0, 10.5, etc.
|
||||
public string Currency { get; set; } = "ARS";
|
||||
|
||||
@@ -19,4 +21,7 @@ public class Product
|
||||
// Propiedades auxiliares para Joins
|
||||
public string? CompanyName { get; set; }
|
||||
public string? TypeCode { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
|
||||
public bool RequiresText { get; set; }
|
||||
public bool HasDuration { get; set; }
|
||||
public bool RequiresCategory { get; set; }
|
||||
}
|
||||
17
src/SIGCM.Domain/Entities/ProductType.cs
Normal file
17
src/SIGCM.Domain/Entities/ProductType.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class ProductType
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public required string Code { get; set; } // 'CLASSIFIED_AD', 'PHYSICAL', etc.
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Icon { get; set; } // Nombre del icono para la UI (lucide)
|
||||
|
||||
// Configuración dinámica
|
||||
public bool HasDuration { get; set; } // Si permite/requiere cantidad de días
|
||||
public bool RequiresText { get; set; } // Si requiere redacción de texto (ej: avisos)
|
||||
public bool RequiresCategory { get; set; } // Si debe estar vinculado a un rubro
|
||||
public bool IsBundle { get; set; } // Si es un combo de otros productos
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
12
src/SIGCM.Domain/Entities/UserProductShortcut.cs
Normal file
12
src/SIGCM.Domain/Entities/UserProductShortcut.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class UserProductShortcut
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public int ProductId { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
|
||||
// Propiedad auxiliar para el frontend
|
||||
public Product? Product { get; set; }
|
||||
}
|
||||
10
src/SIGCM.Domain/Entities/WordPricingRange.cs
Normal file
10
src/SIGCM.Domain/Entities/WordPricingRange.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM.Domain.Entities;
|
||||
|
||||
public class WordPricingRange
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryPricingId { get; set; }
|
||||
public int FromCount { get; set; }
|
||||
public int ToCount { get; set; } // Use a large number or 0 for "infinity"
|
||||
public decimal PricePerWord { get; set; }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ public interface IProductRepository
|
||||
{
|
||||
Task<IEnumerable<Product>> GetAllAsync();
|
||||
Task<IEnumerable<Product>> GetByCompanyIdAsync(int companyId);
|
||||
Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId);
|
||||
Task<Product?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(Product product);
|
||||
Task UpdateAsync(Product product);
|
||||
@@ -15,4 +16,11 @@ public interface IProductRepository
|
||||
Task RemoveComponentFromBundleAsync(int bundleId, int childProductId);
|
||||
Task<decimal> GetCurrentPriceAsync(int productId, DateTime date);
|
||||
Task AddPriceAsync(ProductPrice price);
|
||||
Task DeleteAsync(int id);
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
Task<IEnumerable<UserProductShortcut>> GetUserShortcutsAsync(int userId);
|
||||
Task AddShortcutAsync(int userId, int productId);
|
||||
Task RemoveShortcutAsync(int userId, int productId);
|
||||
Task UpdateShortcutsOrderAsync(int userId, List<int> productIds);
|
||||
}
|
||||
12
src/SIGCM.Domain/Interfaces/IProductTypeRepository.cs
Normal file
12
src/SIGCM.Domain/Interfaces/IProductTypeRepository.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using SIGCM.Domain.Entities;
|
||||
|
||||
namespace SIGCM.Domain.Interfaces;
|
||||
|
||||
public interface IProductTypeRepository
|
||||
{
|
||||
Task<IEnumerable<ProductType>> GetAllAsync();
|
||||
Task<ProductType?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(ProductType productType);
|
||||
Task UpdateAsync(ProductType productType);
|
||||
Task DeleteAsync(int id);
|
||||
}
|
||||
@@ -42,6 +42,22 @@ BEGIN
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductTypes')
|
||||
BEGIN
|
||||
CREATE TABLE ProductTypes (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
Code NVARCHAR(50) NOT NULL UNIQUE,
|
||||
Name NVARCHAR(100) NOT NULL,
|
||||
Description NVARCHAR(255) NULL,
|
||||
Icon NVARCHAR(50) NULL,
|
||||
HasDuration BIT NOT NULL DEFAULT 0,
|
||||
RequiresText BIT NOT NULL DEFAULT 0,
|
||||
RequiresCategory BIT NOT NULL DEFAULT 0,
|
||||
IsBundle BIT NOT NULL DEFAULT 0,
|
||||
IsActive BIT NOT NULL DEFAULT 1
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Operations')
|
||||
BEGIN
|
||||
CREATE TABLE Operations (
|
||||
@@ -115,12 +131,128 @@ BEGIN
|
||||
FOREIGN KEY (ListingId) REFERENCES Listings(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Companies')
|
||||
BEGIN
|
||||
CREATE TABLE Companies (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
Name NVARCHAR(100) NOT NULL,
|
||||
TaxId NVARCHAR(20) NULL,
|
||||
IsActive BIT DEFAULT 1
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Products')
|
||||
BEGIN
|
||||
CREATE TABLE Products (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
CompanyId INT NOT NULL,
|
||||
ProductTypeId INT NOT NULL,
|
||||
CategoryId INT NULL,
|
||||
Name NVARCHAR(200) NOT NULL,
|
||||
Description NVARCHAR(MAX) NULL,
|
||||
SKU NVARCHAR(50) NULL,
|
||||
ExternalId NVARCHAR(100) NULL,
|
||||
BasePrice DECIMAL(18,2) NOT NULL DEFAULT 0,
|
||||
PriceDurationDays INT NOT NULL DEFAULT 1,
|
||||
TaxRate DECIMAL(18,2) NOT NULL DEFAULT 21,
|
||||
Currency NVARCHAR(3) DEFAULT 'ARS',
|
||||
IsActive BIT DEFAULT 1,
|
||||
FOREIGN KEY (CompanyId) REFERENCES Companies(Id),
|
||||
FOREIGN KEY (ProductTypeId) REFERENCES ProductTypes(Id),
|
||||
FOREIGN KEY (CategoryId) REFERENCES Categories(Id)
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductPrices')
|
||||
BEGIN
|
||||
CREATE TABLE ProductPrices (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
ProductId INT NOT NULL,
|
||||
Price DECIMAL(18,2) NOT NULL,
|
||||
ValidFrom DATETIME2 DEFAULT GETUTCDATE(),
|
||||
ValidTo DATETIME2 NULL,
|
||||
CreatedByUserId INT NULL,
|
||||
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ProductBundles')
|
||||
BEGIN
|
||||
CREATE TABLE ProductBundles (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
ParentProductId INT NOT NULL,
|
||||
ChildProductId INT NOT NULL,
|
||||
Quantity INT DEFAULT 1,
|
||||
FixedAllocationAmount DECIMAL(18,2) NULL,
|
||||
FOREIGN KEY (ParentProductId) REFERENCES Products(Id),
|
||||
FOREIGN KEY (ChildProductId) REFERENCES Products(Id)
|
||||
);
|
||||
END
|
||||
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserProductShortcuts')
|
||||
BEGIN
|
||||
CREATE TABLE UserProductShortcuts (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
UserId INT NOT NULL,
|
||||
ProductId INT NOT NULL,
|
||||
DisplayOrder INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||||
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE,
|
||||
CONSTRAINT UQ_User_Product UNIQUE (UserId, ProductId)
|
||||
);
|
||||
END
|
||||
";
|
||||
// Ejecutar creación de tablas base
|
||||
await connection.ExecuteAsync(schemaSql);
|
||||
|
||||
// --- MIGRACIONES (Schema Update) ---
|
||||
var migrationSql = @"
|
||||
-- Products Columns
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'CategoryId' AND Object_ID = Object_ID(N'Products'))
|
||||
BEGIN
|
||||
ALTER TABLE Products ADD CategoryId INT NULL;
|
||||
-- Agregar clave foránea opcionalmente
|
||||
ALTER TABLE Products ADD CONSTRAINT FK_Products_Categories FOREIGN KEY (CategoryId) REFERENCES Categories(Id);
|
||||
END
|
||||
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PriceDurationDays' AND Object_ID = Object_ID(N'Products'))
|
||||
BEGIN
|
||||
ALTER TABLE Products ADD PriceDurationDays INT NOT NULL DEFAULT 1;
|
||||
END
|
||||
|
||||
-- ProductTypes Columns (Migration for existing sparse table)
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Description' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD Description NVARCHAR(255) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'Icon' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD Icon NVARCHAR(50) NULL;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'HasDuration' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD HasDuration BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'RequiresText' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD RequiresText BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'RequiresCategory' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD RequiresCategory BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsBundle' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD IsBundle BIT NOT NULL DEFAULT 0;
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'IsActive' AND Object_ID = Object_ID(N'ProductTypes'))
|
||||
ALTER TABLE ProductTypes ADD IsActive BIT NOT NULL DEFAULT 1;
|
||||
|
||||
-- CategoryPricing Columns
|
||||
IF EXISTS(SELECT * FROM sys.columns WHERE Name = N'BasePrice' AND Object_ID = Object_ID(N'CategoryPricing'))
|
||||
BEGIN
|
||||
-- Drop the default constraint before dropping the column.
|
||||
-- By finding the constraint dynamically:
|
||||
DECLARE @ConstraintName nvarchar(200)
|
||||
SELECT @ConstraintName = Name FROM sys.default_constraints
|
||||
WHERE parent_object_id = object_id('CategoryPricing')
|
||||
AND parent_column_id = columnproperty(object_id('CategoryPricing'), 'BasePrice', 'ColumnId')
|
||||
|
||||
IF @ConstraintName IS NOT NULL
|
||||
EXEC('ALTER TABLE CategoryPricing DROP CONSTRAINT ' + @ConstraintName)
|
||||
|
||||
ALTER TABLE CategoryPricing DROP COLUMN BasePrice;
|
||||
END
|
||||
|
||||
-- Listings Columns
|
||||
IF NOT EXISTS(SELECT * FROM sys.columns WHERE Name = N'PublicationStartDate' AND Object_ID = Object_ID(N'Listings'))
|
||||
ALTER TABLE Listings ADD PublicationStartDate DATETIME2 NULL;
|
||||
@@ -221,6 +353,33 @@ END
|
||||
ALTER TABLE ListingNotes ADD IsFromModerator BIT NOT NULL DEFAULT 1;
|
||||
END
|
||||
END
|
||||
|
||||
-- Tabla de Rangos de Precios por Palabra
|
||||
IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'WordPricingRanges') AND type in (N'U'))
|
||||
BEGIN
|
||||
CREATE TABLE WordPricingRanges (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
CategoryPricingId INT NOT NULL,
|
||||
FromCount INT NOT NULL,
|
||||
ToCount INT NOT NULL,
|
||||
PricePerWord DECIMAL(18,2) NOT NULL,
|
||||
FOREIGN KEY (CategoryPricingId) REFERENCES CategoryPricing(Id) ON DELETE CASCADE
|
||||
);
|
||||
END
|
||||
|
||||
-- UserProductShortcuts Table
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserProductShortcuts')
|
||||
BEGIN
|
||||
CREATE TABLE UserProductShortcuts (
|
||||
Id INT IDENTITY(1,1) PRIMARY KEY,
|
||||
UserId INT NOT NULL,
|
||||
ProductId INT NOT NULL,
|
||||
DisplayOrder INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (UserId) REFERENCES Users(Id),
|
||||
FOREIGN KEY (ProductId) REFERENCES Products(Id) ON DELETE CASCADE,
|
||||
CONSTRAINT UQ_User_Product UNIQUE (UserId, ProductId)
|
||||
);
|
||||
END
|
||||
";
|
||||
await connection.ExecuteAsync(migrationSql);
|
||||
|
||||
@@ -312,6 +471,23 @@ END
|
||||
";
|
||||
await connection.ExecuteAsync(upgradeClientsSql);
|
||||
|
||||
|
||||
// --- SEED DE TIPOS DE PRODUCTO ---
|
||||
var ptCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM ProductTypes");
|
||||
if (ptCount == 0)
|
||||
{
|
||||
var seedTypesSql = @"
|
||||
INSERT INTO ProductTypes (Code, Name, Description, Icon, HasDuration, RequiresText, RequiresCategory, IsBundle)
|
||||
VALUES
|
||||
('CLASSIFIED_AD', 'Aviso Clasificado', 'Anuncio de texto en secciones categorizadas', 'Type', 1, 1, 1, 0),
|
||||
('GRAPHIC_AD', 'Publicidad Gráfica', 'Anuncios visuales por módulos', 'Image', 1, 0, 1, 0),
|
||||
('RADIO_AD', 'Publicidad Radial', 'Pauta comercial en radio', 'Mic', 1, 0, 1, 0),
|
||||
('PHYSICAL', 'Producto Físico', 'Venta de bienes materiales', 'Package', 0, 0, 0, 0),
|
||||
('SERVICE', 'Servicio', 'Prestación de servicios profesionales', 'LifeBuoy', 1, 0, 0, 0),
|
||||
('BUNDLE', 'Paquete Promocional (Combo)', 'Agrupación de múltiples productos', 'Layers', 0, 0, 0, 1)";
|
||||
await connection.ExecuteAsync(seedTypesSql);
|
||||
}
|
||||
|
||||
// --- SEED DE DATOS (Usuario Admin) ---
|
||||
var adminCount = await connection.ExecuteScalarAsync<int>("SELECT COUNT(1) FROM Users WHERE Username = 'admin'");
|
||||
|
||||
|
||||
@@ -42,7 +42,8 @@ public static class DependencyInjection
|
||||
services.AddScoped<IClientProfileRepository, ClientProfileRepository>();
|
||||
services.AddScoped<ICompanyRepository, CompanyRepository>();
|
||||
services.AddScoped<IReportRepository, ReportRepository>();
|
||||
services.AddScoped<ICalendarRepository, CalendarRepository>();;
|
||||
services.AddScoped<ICalendarRepository, CalendarRepository>();
|
||||
services.AddScoped<IProductTypeRepository, ProductTypeRepository>();
|
||||
|
||||
// Registro de MercadoPagoService configurado desde IConfiguration (appsettings o env vars)
|
||||
services.AddScoped<MercadoPagoService>(sp =>
|
||||
|
||||
@@ -81,7 +81,7 @@ public class ClientRepository
|
||||
}
|
||||
|
||||
sql += " ORDER BY name";
|
||||
|
||||
|
||||
return await conn.QueryAsync(sql, new { Query = $"%{searchTerm}%" });
|
||||
}
|
||||
|
||||
@@ -136,4 +136,24 @@ public class ClientRepository
|
||||
var sql = "UPDATE Users SET PasswordHash = @Hash, MustChangePassword = 1 WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, new { Hash = passwordHash, Id = clientId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateFullClientAsync(Client client)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Users (
|
||||
Username, PasswordHash, Role,
|
||||
BillingName, BillingTaxId, Email, Phone, BillingAddress, BillingTaxType,
|
||||
MustChangePassword, IsActive, CreatedAt
|
||||
)
|
||||
VALUES (
|
||||
@DniOrCuit, 'N/A', 'Client',
|
||||
@Name, @DniOrCuit, @Email, @Phone, @Address, @TaxType,
|
||||
0, 1, GETUTCDATE()
|
||||
);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
|
||||
// Usamos el CUIT como username por defecto para garantizar unicidad
|
||||
return await conn.QuerySingleAsync<int>(sql, client);
|
||||
}
|
||||
}
|
||||
@@ -16,36 +16,58 @@ public class PricingRepository
|
||||
public async Task<CategoryPricing?> GetByCategoryIdAsync(int categoryId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
|
||||
"SELECT * FROM CategoryPricing WHERE CategoryId = @Id", new { Id = categoryId });
|
||||
var sql = @"
|
||||
SELECT * FROM CategoryPricing WHERE CategoryId = @Id;
|
||||
SELECT * FROM WordPricingRanges WHERE CategoryPricingId = (SELECT Id FROM CategoryPricing WHERE CategoryId = @Id) ORDER BY FromCount;";
|
||||
|
||||
using var multi = await conn.QueryMultipleAsync(sql, new { Id = categoryId });
|
||||
var pricing = await multi.ReadSingleOrDefaultAsync<CategoryPricing>();
|
||||
if (pricing != null)
|
||||
{
|
||||
pricing.WordRanges = (await multi.ReadAsync<WordPricingRange>()).ToList();
|
||||
}
|
||||
return pricing;
|
||||
}
|
||||
|
||||
public async Task UpsertPricingAsync(CategoryPricing pricing)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// Lógica de "Si existe actualiza, sino inserta"
|
||||
var exists = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
|
||||
var exists = await conn.QuerySingleOrDefaultAsync<CategoryPricing>(
|
||||
"SELECT Id FROM CategoryPricing WHERE CategoryId = @CategoryId", new { pricing.CategoryId });
|
||||
|
||||
if (exists > 0)
|
||||
if (exists != null)
|
||||
{
|
||||
pricing.Id = exists.Id;
|
||||
var updateSql = @"
|
||||
UPDATE CategoryPricing
|
||||
SET BasePrice = @BasePrice, BaseWordCount = @BaseWordCount,
|
||||
SET BaseWordCount = @BaseWordCount,
|
||||
ExtraWordPrice = @ExtraWordPrice, SpecialChars = @SpecialChars,
|
||||
SpecialCharPrice = @SpecialCharPrice, BoldSurcharge = @BoldSurcharge,
|
||||
FrameSurcharge = @FrameSurcharge
|
||||
WHERE CategoryId = @CategoryId";
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(updateSql, pricing);
|
||||
}
|
||||
else
|
||||
{
|
||||
var insertSql = @"
|
||||
INSERT INTO CategoryPricing
|
||||
(CategoryId, BasePrice, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge)
|
||||
(CategoryId, BaseWordCount, ExtraWordPrice, SpecialChars, SpecialCharPrice, BoldSurcharge, FrameSurcharge)
|
||||
VALUES
|
||||
(@CategoryId, @BasePrice, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge)";
|
||||
await conn.ExecuteAsync(insertSql, pricing);
|
||||
(@CategoryId, @BaseWordCount, @ExtraWordPrice, @SpecialChars, @SpecialCharPrice, @BoldSurcharge, @FrameSurcharge);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
pricing.Id = await conn.QuerySingleAsync<int>(insertSql, pricing);
|
||||
}
|
||||
|
||||
// Gestionar Rangos de Palabras
|
||||
await conn.ExecuteAsync("DELETE FROM WordPricingRanges WHERE CategoryPricingId = @Id", new { Id = pricing.Id });
|
||||
if (pricing.WordRanges != null && pricing.WordRanges.Any())
|
||||
{
|
||||
foreach (var range in pricing.WordRanges)
|
||||
{
|
||||
range.CategoryPricingId = pricing.Id;
|
||||
}
|
||||
var rangeSql = "INSERT INTO WordPricingRanges (CategoryPricingId, FromCount, ToCount, PricePerWord) VALUES (@CategoryPricingId, @FromCount, @ToCount, @PricePerWord)";
|
||||
await conn.ExecuteAsync(rangeSql, pricing.WordRanges);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ public class ProductRepository : IProductRepository
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode
|
||||
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode,
|
||||
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM Products p
|
||||
JOIN Companies c ON p.CompanyId = c.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
@@ -27,7 +28,8 @@ public class ProductRepository : IProductRepository
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode
|
||||
SELECT p.*, c.Name as CompanyName, pt.Code as TypeCode,
|
||||
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM Products p
|
||||
JOIN Companies c ON p.CompanyId = c.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
@@ -43,12 +45,39 @@ public class ProductRepository : IProductRepository
|
||||
new { Id = companyId });
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Product>> GetByCategoryIdAsync(int categoryId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// CTE recursivo: sube por la jerarquía de categorías para incluir
|
||||
// productos asignados al rubro solicitado O a cualquiera de sus ancestros.
|
||||
// Ejemplo: consultando "Autos" devuelve productos de "Autos" Y de "Vehículos".
|
||||
var sql = @"
|
||||
WITH CategoryAncestors AS (
|
||||
-- Nodo raíz: la categoría pedida
|
||||
SELECT Id, ParentId FROM Categories WHERE Id = @CategoryId
|
||||
UNION ALL
|
||||
-- Subir un nivel: el padre de cada nodo ya en el CTE
|
||||
SELECT c.Id, c.ParentId
|
||||
FROM Categories c
|
||||
INNER JOIN CategoryAncestors ca ON c.Id = ca.ParentId
|
||||
)
|
||||
SELECT p.*, comp.Name as CompanyName, pt.Code as TypeCode,
|
||||
pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM Products p
|
||||
JOIN Companies comp ON p.CompanyId = comp.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
WHERE p.CategoryId IN (SELECT Id FROM CategoryAncestors)
|
||||
AND p.IsActive = 1
|
||||
OPTION (MAXRECURSION 20)";
|
||||
return await conn.QueryAsync<Product>(sql, new { CategoryId = categoryId });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(Product product)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO Products (CompanyId, ProductTypeId, Name, Description, SKU, BasePrice, TaxRate, IsActive)
|
||||
VALUES (@CompanyId, @ProductTypeId, @Name, @Description, @SKU, @BasePrice, @TaxRate, @IsActive);
|
||||
INSERT INTO Products (CompanyId, ProductTypeId, CategoryId, Name, Description, SKU, BasePrice, PriceDurationDays, TaxRate, IsActive)
|
||||
VALUES (@CompanyId, @ProductTypeId, @CategoryId, @Name, @Description, @SKU, @BasePrice, @PriceDurationDays, @TaxRate, @IsActive);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, product);
|
||||
}
|
||||
@@ -58,7 +87,7 @@ public class ProductRepository : IProductRepository
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE Products
|
||||
SET Name = @Name, Description = @Description, BasePrice = @BasePrice, TaxRate = @TaxRate, IsActive = @IsActive
|
||||
SET CategoryId = @CategoryId, Name = @Name, Description = @Description, BasePrice = @BasePrice, PriceDurationDays = @PriceDurationDays, TaxRate = @TaxRate, IsActive = @IsActive
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, product);
|
||||
}
|
||||
@@ -73,9 +102,10 @@ public class ProductRepository : IProductRepository
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT pb.*, p.*
|
||||
SELECT pb.*, p.*, pt.Code as TypeCode, pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM ProductBundles pb
|
||||
JOIN Products p ON pb.ChildProductId = p.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
WHERE pb.ParentProductId = @ParentProductId";
|
||||
|
||||
// Usamos Dapper Multi-Mapping para llenar el objeto ChildProduct
|
||||
@@ -123,8 +153,8 @@ public class ProductRepository : IProductRepository
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @ChildId",
|
||||
new { ParentId = bundleId, ChildId = childProductId });
|
||||
"DELETE FROM ProductBundles WHERE ParentProductId = @ParentId AND ChildProductId = @Id",
|
||||
new { ParentId = bundleId, Id = childProductId });
|
||||
}
|
||||
|
||||
// Obtener el precio vigente para una fecha dada
|
||||
@@ -161,4 +191,129 @@ public class ProductRepository : IProductRepository
|
||||
VALUES (@ProductId, @Price, @ValidFrom, @ValidTo, @CreatedByUserId)";
|
||||
await conn.ExecuteAsync(sql, price);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
|
||||
// 1. Verificar si está siendo usado como componente de un combo
|
||||
// Usamos IF OBJECT_ID para que sea robusto si la tabla no existe por algún motivo
|
||||
var checkBundleSql = @"
|
||||
IF OBJECT_ID('ProductBundles') IS NOT NULL
|
||||
SELECT COUNT(1) FROM ProductBundles WHERE ChildProductId = @Id
|
||||
ELSE
|
||||
SELECT 0";
|
||||
var usedInBundle = await conn.ExecuteScalarAsync<int>(checkBundleSql, new { Id = id });
|
||||
|
||||
if (usedInBundle > 0)
|
||||
{
|
||||
throw new InvalidOperationException("No se puede eliminar el producto porque forma parte de uno o más combos.");
|
||||
}
|
||||
|
||||
// 2. Verificar si tiene registros asociados (ej: Listings)
|
||||
// Usamos SQL dinámico con sp_executesql para evitar errores de compilación si la columna no existe
|
||||
var checkSalesSql = @"
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE Name = 'ProductId' AND Object_ID = OBJECT_ID('Listings'))
|
||||
BEGIN
|
||||
EXEC sp_executesql N'SELECT COUNT(1) FROM Listings WHERE ProductId = @Id', N'@Id INT', @Id
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SELECT 0
|
||||
END";
|
||||
|
||||
var hasSales = await conn.ExecuteScalarAsync<int>(checkSalesSql, new { Id = id });
|
||||
|
||||
if (hasSales > 0)
|
||||
{
|
||||
throw new InvalidOperationException("No se puede eliminar el producto porque ya tiene ventas o registros asociados.");
|
||||
}
|
||||
|
||||
// 3. Eliminar registros permitidos (Precios e hijos de bundle si es un combo)
|
||||
await conn.ExecuteAsync(@"
|
||||
IF OBJECT_ID('ProductPrices') IS NOT NULL DELETE FROM ProductPrices WHERE ProductId = @Id;
|
||||
IF OBJECT_ID('ProductBundles') IS NOT NULL DELETE FROM ProductBundles WHERE ParentProductId = @Id;
|
||||
", new { Id = id });
|
||||
|
||||
// 4. Eliminar el producto final
|
||||
await conn.ExecuteAsync("DELETE FROM Products WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
// --- SHORTCUTS (ACCESOS RÁPIDOS) ---
|
||||
|
||||
public async Task<IEnumerable<UserProductShortcut>> GetUserShortcutsAsync(int userId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
SELECT s.*, p.*, pt.Code as TypeCode, pt.RequiresText, pt.HasDuration, pt.RequiresCategory
|
||||
FROM UserProductShortcuts s
|
||||
JOIN Products p ON s.ProductId = p.Id
|
||||
JOIN ProductTypes pt ON p.ProductTypeId = pt.Id
|
||||
WHERE s.UserId = @UserId AND p.IsActive = 1
|
||||
ORDER BY s.DisplayOrder ASC";
|
||||
|
||||
return await conn.QueryAsync<UserProductShortcut, Product, UserProductShortcut>(
|
||||
sql,
|
||||
(shortcut, product) =>
|
||||
{
|
||||
shortcut.Product = product;
|
||||
return shortcut;
|
||||
},
|
||||
new { UserId = userId },
|
||||
splitOn: "Id"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task AddShortcutAsync(int userId, int productId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// 1. Verificar si ya existe
|
||||
var exists = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM UserProductShortcuts WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { UserId = userId, ProductId = productId });
|
||||
|
||||
if (exists > 0) return;
|
||||
|
||||
// 2. Obtener el orden máximo actual
|
||||
var maxOrder = await conn.ExecuteScalarAsync<int?>(
|
||||
"SELECT MAX(DisplayOrder) FROM UserProductShortcuts WHERE UserId = @UserId",
|
||||
new { UserId = userId }) ?? 0;
|
||||
|
||||
// 3. Insertar
|
||||
var sql = @"
|
||||
INSERT INTO UserProductShortcuts (UserId, ProductId, DisplayOrder)
|
||||
VALUES (@UserId, @ProductId, @DisplayOrder)";
|
||||
await conn.ExecuteAsync(sql, new { UserId = userId, ProductId = productId, DisplayOrder = maxOrder + 1 });
|
||||
}
|
||||
|
||||
public async Task RemoveShortcutAsync(int userId, int productId)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
await conn.ExecuteAsync(
|
||||
"DELETE FROM UserProductShortcuts WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { UserId = userId, ProductId = productId });
|
||||
}
|
||||
|
||||
public async Task UpdateShortcutsOrderAsync(int userId, List<int> productIds)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
conn.Open();
|
||||
using var trans = conn.BeginTransaction();
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < productIds.Count; i++)
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE UserProductShortcuts SET DisplayOrder = @Order WHERE UserId = @UserId AND ProductId = @ProductId",
|
||||
new { Order = i, UserId = userId, ProductId = productIds[i] },
|
||||
transaction: trans);
|
||||
}
|
||||
trans.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
trans.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Dapper;
|
||||
using SIGCM.Domain.Entities;
|
||||
using SIGCM.Domain.Interfaces;
|
||||
using SIGCM.Infrastructure.Data;
|
||||
|
||||
namespace SIGCM.Infrastructure.Repositories;
|
||||
|
||||
public class ProductTypeRepository : IProductTypeRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _db;
|
||||
|
||||
public ProductTypeRepository(IDbConnectionFactory db) => _db = db;
|
||||
|
||||
public async Task<IEnumerable<ProductType>> GetAllAsync()
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QueryAsync<ProductType>("SELECT * FROM ProductTypes WHERE IsActive = 1");
|
||||
}
|
||||
|
||||
public async Task<ProductType?> GetByIdAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
return await conn.QuerySingleOrDefaultAsync<ProductType>(
|
||||
"SELECT * FROM ProductTypes WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(ProductType productType)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
INSERT INTO ProductTypes (Code, Name, Description, Icon, HasDuration, RequiresText, RequiresCategory, IsBundle, IsActive)
|
||||
VALUES (@Code, @Name, @Description, @Icon, @HasDuration, @RequiresText, @RequiresCategory, @IsBundle, @IsActive);
|
||||
SELECT CAST(SCOPE_IDENTITY() as int);";
|
||||
return await conn.QuerySingleAsync<int>(sql, productType);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ProductType productType)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
var sql = @"
|
||||
UPDATE ProductTypes
|
||||
SET Code = @Code, Name = @Name, Description = @Description, Icon = @Icon,
|
||||
HasDuration = @HasDuration, RequiresText = @RequiresText,
|
||||
RequiresCategory = @RequiresCategory, IsBundle = @IsBundle, IsActive = @IsActive
|
||||
WHERE Id = @Id";
|
||||
await conn.ExecuteAsync(sql, productType);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id)
|
||||
{
|
||||
using var conn = _db.CreateConnection();
|
||||
// Verificar si hay productos que usen este tipo antes de borrar
|
||||
var count = await conn.ExecuteScalarAsync<int>(
|
||||
"SELECT COUNT(1) FROM Products WHERE ProductTypeId = @Id", new { Id = id });
|
||||
|
||||
if (count > 0) throw new InvalidOperationException("No se puede eliminar el tipo porque tiene productos asociados.");
|
||||
|
||||
await conn.ExecuteAsync("DELETE FROM ProductTypes WHERE Id = @Id", new { Id = id });
|
||||
}
|
||||
}
|
||||
@@ -85,9 +85,8 @@ public class OrderService : IOrderService
|
||||
}
|
||||
}
|
||||
|
||||
// B. OBTENER PRECIO VIGENTE (Histórico)
|
||||
// Usamos la fecha actual para determinar el precio.
|
||||
decimal currentUnitPrice = await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
|
||||
// B. OBTENER PRECIO VIGENTE (Histórico o Manual)
|
||||
decimal currentUnitPrice = itemDto.UnitPrice ?? await _productRepo.GetCurrentPriceAsync(product.Id, DateTime.UtcNow);
|
||||
|
||||
// C. LÓGICA DE COMBOS VS SIMPLE
|
||||
if (product.TypeCode == "BUNDLE")
|
||||
|
||||
@@ -10,23 +10,47 @@ public class PricingService
|
||||
{
|
||||
private readonly PricingRepository _repo;
|
||||
private readonly ICouponRepository _couponRepo;
|
||||
private readonly IProductRepository _productRepo;
|
||||
|
||||
public PricingService(PricingRepository repo, ICouponRepository couponRepo)
|
||||
public PricingService(PricingRepository repo, ICouponRepository couponRepo, IProductRepository productRepo)
|
||||
{
|
||||
_repo = repo;
|
||||
_couponRepo = couponRepo;
|
||||
_productRepo = productRepo;
|
||||
}
|
||||
|
||||
public async Task<CalculatePriceResponse> CalculateAsync(CalculatePriceRequest request)
|
||||
{
|
||||
// 0. Obtener el Producto para saber el Precio Base
|
||||
// Si ProductId = 0, el llamador no seleccionó un producto (ej: FastEntryPage legacy).
|
||||
// En ese caso fallamos graciosamente con precio base 0.
|
||||
decimal productBasePrice = 0;
|
||||
Product? product = null;
|
||||
if (request.ProductId > 0)
|
||||
{
|
||||
product = await _productRepo.GetByIdAsync(request.ProductId);
|
||||
if (product == null) return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = 0,
|
||||
Details = "Producto no encontrado."
|
||||
};
|
||||
|
||||
decimal retrievedPrice = await _productRepo.GetCurrentPriceAsync(request.ProductId, request.StartDate == default ? DateTime.UtcNow : request.StartDate);
|
||||
|
||||
// Si el precio es por X días (ej: 30), el costo diario es Precio / X
|
||||
int prodDuration = product.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
|
||||
productBasePrice = retrievedPrice / prodDuration;
|
||||
}
|
||||
|
||||
// 1. Obtener Reglas
|
||||
var pricing = await _repo.GetByCategoryIdAsync(request.CategoryId);
|
||||
|
||||
// Si no hay configuración para este rubro, devolvemos 0 o un default seguro
|
||||
// Si no hay configuración para este rubro, devolvemos al menos el precio base del producto
|
||||
if (pricing == null) return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = 0,
|
||||
Details = "No hay tarifas configuradas para este rubro."
|
||||
TotalPrice = productBasePrice * request.Days,
|
||||
BaseCost = productBasePrice * request.Days,
|
||||
Details = "El rubro no tiene tarifas configuradas. Se aplica solo el precio base del producto."
|
||||
};
|
||||
|
||||
// 2. Análisis del Texto
|
||||
@@ -43,28 +67,50 @@ public class PricingService
|
||||
var words = cleanText.Split(new[] { ' ', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
int realWordCount = words.Length;
|
||||
|
||||
// 3. Costo Base y Excedente
|
||||
decimal currentCost = pricing.BasePrice; // Precio base incluye N palabras
|
||||
// 3. Costo Base y Excedente (productBasePrice ya viene resuelto desde sección 0)
|
||||
int calcDuration = product?.PriceDurationDays > 0 ? product.PriceDurationDays : 1;
|
||||
decimal currentCost = productBasePrice; // Precio base del Producto seleccionado (ya dividido por duration si aplica)
|
||||
|
||||
|
||||
// ¿Cuántas palabras extra cobramos?
|
||||
// Nota: Los caracteres especiales se cobran aparte según tu requerimiento,
|
||||
// o suman al conteo de palabras. Aquí implemento: Se cobran APARTE.
|
||||
|
||||
int extraWords = Math.Max(0, realWordCount - pricing.BaseWordCount);
|
||||
decimal extraWordCost = extraWords * pricing.ExtraWordPrice;
|
||||
decimal specialCharCost = specialCharCount * pricing.SpecialCharPrice;
|
||||
decimal extraWordCost = 0;
|
||||
|
||||
currentCost += extraWordCost + specialCharCost;
|
||||
if (pricing.WordRanges != null && pricing.WordRanges.Any() && extraWords > 0)
|
||||
{
|
||||
var applicableRange = pricing.WordRanges.FirstOrDefault(r =>
|
||||
extraWords >= r.FromCount && (extraWords <= r.ToCount || r.ToCount == 0));
|
||||
|
||||
if (applicableRange != null)
|
||||
{
|
||||
extraWordCost = extraWords * applicableRange.PricePerWord;
|
||||
}
|
||||
else
|
||||
{
|
||||
extraWordCost = extraWords * pricing.ExtraWordPrice;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
extraWordCost = extraWords * pricing.ExtraWordPrice;
|
||||
}
|
||||
|
||||
// Dividimos los costos extra por la duración para convertirlos en tasa diaria
|
||||
// si el precio base del producto también es por un periodo > 1 día.
|
||||
decimal dailyExtraWordCost = extraWordCost / calcDuration;
|
||||
decimal dailySpecialCharCost = (specialCharCount * pricing.SpecialCharPrice) / calcDuration;
|
||||
|
||||
currentCost += dailyExtraWordCost + dailySpecialCharCost;
|
||||
|
||||
// 4. Estilos (Negrita / Recuadro / Destacado)
|
||||
if (request.IsBold) currentCost += pricing.BoldSurcharge;
|
||||
if (request.IsFrame) currentCost += pricing.FrameSurcharge;
|
||||
if (request.IsBold) currentCost += pricing.BoldSurcharge / calcDuration;
|
||||
if (request.IsFrame) currentCost += pricing.FrameSurcharge / calcDuration;
|
||||
|
||||
// Costo Destacado (Hardcoded por ahora o agregar a regla)
|
||||
// Costo Destacado
|
||||
decimal featuredSurcharge = 0;
|
||||
if (request.IsFeatured)
|
||||
{
|
||||
featuredSurcharge = 500m; // Valor ejemplo por día
|
||||
featuredSurcharge = 500m; // Se asume 500 por día si no se especifica lo contrario
|
||||
currentCost += featuredSurcharge;
|
||||
}
|
||||
|
||||
@@ -132,13 +178,15 @@ public class PricingService
|
||||
return new CalculatePriceResponse
|
||||
{
|
||||
TotalPrice = Math.Max(0, totalBeforeDiscount - totalDiscount),
|
||||
BaseCost = pricing.BasePrice * request.Days,
|
||||
ExtraCost = (extraWordCost + specialCharCost) * request.Days,
|
||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge : 0) + (request.IsFrame ? pricing.FrameSurcharge : 0) + (request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
|
||||
BaseCost = productBasePrice * request.Days,
|
||||
ExtraCost = (dailyExtraWordCost + dailySpecialCharCost) * request.Days,
|
||||
Surcharges = ((request.IsBold ? pricing.BoldSurcharge / calcDuration : 0) +
|
||||
(request.IsFrame ? pricing.FrameSurcharge / calcDuration : 0) +
|
||||
(request.IsFeatured ? featuredSurcharge : 0)) * request.Days,
|
||||
Discount = totalDiscount,
|
||||
WordCount = realWordCount,
|
||||
SpecialCharCount = specialCharCount,
|
||||
Details = $"Tarifa Diaria: ${currentCost} x {request.Days} días. (Extras diarios: ${extraWordCost + specialCharCost}). {string.Join(", ", appliedPromos)}",
|
||||
Details = $"Tarifa Diaria: ${currentCost:N2} x {request.Days} días. (Extras diarios: ${dailyExtraWordCost + dailySpecialCharCost:N2}). {string.Join(", ", appliedPromos)}",
|
||||
AppliedPromotion = string.Join(", ", appliedPromos)
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user