Feat: Configuración y Administración de Tipos
This commit is contained in:
@@ -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,9 @@
|
||||
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';
|
||||
|
||||
@@ -14,22 +15,13 @@ interface Props {
|
||||
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, categories, allProducts, onClose }: Props) {
|
||||
const [formData, setFormData] = useState<Partial<Product>>({
|
||||
name: '',
|
||||
description: '',
|
||||
companyId: 0,
|
||||
categoryId: undefined,
|
||||
productTypeId: 4, // Default Physical
|
||||
productTypeId: 0,
|
||||
basePrice: 0,
|
||||
priceDurationDays: 1,
|
||||
taxRate: 21,
|
||||
@@ -37,35 +29,48 @@ export default function ProductModal({ product, companies, categories, allProduc
|
||||
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 [hasDuration, setHasDuration] = useState(false);
|
||||
const [hasDurationUI, setHasDurationUI] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
productTypeService.getAll().then(setProductTypes).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormData(product);
|
||||
setHasDuration(product.priceDurationDays > 1);
|
||||
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');
|
||||
const type = productTypes.find(t => t.id === formData.productTypeId);
|
||||
if (!type) return;
|
||||
|
||||
// Si es producto físico o combo, forzamos duración 1 (sin periodo)
|
||||
if (formData.productTypeId === 4 || formData.productTypeId === 6) {
|
||||
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]);
|
||||
}, [formData.productTypeId, productTypes]);
|
||||
|
||||
const loadBundleComponents = async (productId: number) => {
|
||||
try {
|
||||
@@ -150,7 +155,7 @@ export default function ProductModal({ product, companies, categories, allProduc
|
||||
<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 })}
|
||||
@@ -198,27 +203,27 @@ export default function ProductModal({ product, companies, categories, allProduc
|
||||
value={formData.basePrice} onChange={e => setFormData({ ...formData, basePrice: parseFloat(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
{formData.productTypeId !== 4 && formData.productTypeId !== 6 && (
|
||||
{productTypes.find(t => t.id === formData.productTypeId)?.hasDuration && (
|
||||
<div className="col-span-2 space-y-3">
|
||||
<div
|
||||
onClick={() => {
|
||||
const newValue = !hasDuration;
|
||||
setHasDuration(newValue);
|
||||
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 ${hasDuration ? 'border-blue-500 bg-blue-50' : 'border-slate-100 bg-slate-50 opacity-60'
|
||||
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 ${hasDuration ? 'bg-blue-600' : 'bg-slate-300'}`}>
|
||||
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${hasDuration ? 'right-1' : 'left-1'}`} />
|
||||
<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>
|
||||
|
||||
{hasDuration && (
|
||||
{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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export default function ProtectedLayout() {
|
||||
title: "Catálogo & Precios",
|
||||
items: [
|
||||
{ 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 de Descuento', href: '/coupons', icon: <Tag size={18} />, roles: ['Admin'] },
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -27,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;
|
||||
}
|
||||
Reference in New Issue
Block a user