feat(suppliers-orders): sincronizar modulo de proveedores, gestion de pedidos, api evolution v2 y compilacion apk en todas las aplicaciones
This commit is contained in:
parent
4e1476e1a9
commit
e675ec3641
@ -15,6 +15,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
||||
{ href: '/admin/proveedores', label: 'Proveedores & Bodegas', icon: Building2, badge: 'Activos/Inactivos' },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,11 @@ export default function AdminProductsPage() {
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/suppliers?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && d.suppliers && setSuppliers(d.suppliers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
|
||||
593
almeira-store-app/src/app/admin/proveedores/page.tsx
Normal file
593
almeira-store-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Phone,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Edit2,
|
||||
Power,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
User,
|
||||
Info,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Supplier, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
|
||||
export default function AdminSuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [contactPerson, setContactPerson] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.suppliers) {
|
||||
setSuppliers(data.suppliers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching suppliers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/products?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.products) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching products:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditingSupplier(null);
|
||||
setName('');
|
||||
setPhone('57');
|
||||
setContactPerson('');
|
||||
setAddress('');
|
||||
setNotes('');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier);
|
||||
setName(supplier.name);
|
||||
setPhone(supplier.phone);
|
||||
setContactPerson(supplier.contactPerson || '');
|
||||
setAddress(supplier.address || '');
|
||||
setNotes(supplier.notes || '');
|
||||
setIsActive(supplier.isActive !== false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (supplier: Supplier) => {
|
||||
const nextStatus = !supplier.isActive;
|
||||
|
||||
// Optimistic update
|
||||
setSuppliers((prev) =>
|
||||
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: supplier.id,
|
||||
updates: { isActive: nextStatus },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(
|
||||
nextStatus
|
||||
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||
);
|
||||
setTimeout(() => setToast(null), 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling supplier active status:', e);
|
||||
fetchSuppliers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
// Edit existing
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSupplier.id,
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||
const text = encodeURIComponent(
|
||||
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Almeira Store.`
|
||||
);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||
|
||||
const filteredSuppliers = suppliers.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.phone.includes(search) ||
|
||||
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||
return products.filter(
|
||||
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||
).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Building2 className="w-6 h-6 text-cyan-400" />
|
||||
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Proveedor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="bg-cyan-950/90 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Total Proveedores</span>
|
||||
<Building2 className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Bodegas y mayoristas registrados</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Proveedores Activos</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Desactivados / Históricos</span>
|
||||
<XCircle className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Historial preservado 100% intacto</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Zapatillas Vinculadas</span>
|
||||
<Package className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-purple-300">{products.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Modelos en catálogo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner About Historical Safety */}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-900 to-cyan-950/40 border border-cyan-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-slate-300 shadow-md">
|
||||
<Info className="w-5 h-5 text-cyan-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-white">
|
||||
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({suppliers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||
: 'text-slate-400 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Activos ({activeSuppliers.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||
: 'text-slate-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSuppliers.length === 0 ? (
|
||||
<div className="col-span-full bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center text-slate-500 space-y-2">
|
||||
<Building2 className="w-12 h-12 text-slate-600 mx-auto" />
|
||||
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSuppliers.map((supplier) => {
|
||||
const isSupActive = supplier.isActive !== false;
|
||||
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className={`bg-slate-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||
isSupActive
|
||||
? 'border-slate-800 hover:border-cyan-500/40'
|
||||
: 'border-slate-800/80 opacity-75 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* Card Top: Status & Name */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||
isSupActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleActive(supplier)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isSupActive
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||
>
|
||||
<Power className="w-3 h-3" />
|
||||
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||
|
||||
{/* Details List */}
|
||||
<div className="space-y-1.5 text-xs pt-1">
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||
</p>
|
||||
|
||||
{supplier.address && (
|
||||
<p className="text-slate-400 flex items-center gap-1.5 text-[11px]">
|
||||
<MapPin className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||
<span>{supplier.address}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supplier.notes && (
|
||||
<p className="text-slate-400 italic text-[11px] bg-slate-950 p-2 rounded-xl border border-slate-800 mt-1">
|
||||
"{supplier.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Bottom: Metrics & Actions */}
|
||||
<div className="pt-3 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Modelos asignados:</span>
|
||||
</span>
|
||||
<strong className="text-white bg-slate-950 px-2 py-0.5 rounded-lg border border-slate-800">
|
||||
{linkedProductsCount} pares
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenWhatsApp(supplier)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(supplier)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Modificar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Crear / Editar Proveedor */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-cyan-400" />
|
||||
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 573167455877"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Persona de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Asesor Despachos"
|
||||
value={contactPerson}
|
||||
onChange={(e) => setContactPerson(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{isActive
|
||||
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateOrder(targetId, payload);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = dataService.deleteOrder(id);
|
||||
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
71
almeira-store-app/src/app/api/suppliers/route.ts
Normal file
71
almeira-store-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||
const suppliers = dataService.getSuppliers(includeInactive);
|
||||
return NextResponse.json({ success: true, suppliers });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !body.phone) {
|
||||
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||
}
|
||||
const newSupplier = dataService.addSupplier(body);
|
||||
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateSupplier(targetId, payload);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, supplier: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||
const success = dataService.deleteSupplier(id);
|
||||
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, Check } from 'lucide-react';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
interface MobileBottomDockProps {
|
||||
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
const [isInsideApp, setIsInsideApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isNativeApp =
|
||||
typeof window !== 'undefined' &&
|
||||
(!!(window as any).Capacitor?.isNativePlatform?.() ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'android' ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'ios' ||
|
||||
window.matchMedia?.('(display-mode: standalone)')?.matches ||
|
||||
(window.navigator as any)?.standalone === true);
|
||||
setIsInsideApp(isNativeApp);
|
||||
}, []);
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const text = encodeURIComponent(
|
||||
@ -88,17 +100,28 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
{/* 5. Instalar App */}
|
||||
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
|
||||
{!isInsideApp ? (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className={`flex flex-col items-center gap-0.5 cursor-pointer text-cyan-400 hover:text-cyan-300`}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-cyan-400 hover:text-cyan-300"
|
||||
>
|
||||
<div className="relative">
|
||||
<Download className="w-5 h-5 animate-bounce" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold">App Móvil</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-[10px] font-bold">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ import { User } from '@/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
sendOtp: (identifier: string) => Promise<{ success: boolean; error?: string; message?: string; phone?: string }>;
|
||||
verifyOtp: (identifier: string, code: string) => Promise<{ success: boolean; error?: string }>;
|
||||
login: (identifier: string) => Promise<{ success: boolean; error?: string }>;
|
||||
register: (data: { name: string; email: string; phone: string; city?: string; address?: string }) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
@ -28,26 +30,47 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (identifier: string) => {
|
||||
const sendOtp = async (identifier: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
const res = await fetch('/api/auth/send-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
return { success: true, message: data.message, phone: data.phone };
|
||||
}
|
||||
return { success: false, error: data.error || 'Error al enviar código' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOtp = async (identifier: string, code: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier, code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.user) {
|
||||
setUser(data.user);
|
||||
localStorage.setItem('almeira_user', JSON.stringify(data.user));
|
||||
setIsAuthModalOpen(false);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.error || 'Error al iniciar sesión' };
|
||||
return { success: false, error: data.error || 'Código incorrecto' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (identifier: string) => {
|
||||
return sendOtp(identifier);
|
||||
};
|
||||
|
||||
const register = async (data: { name: string; email: string; phone: string; city?: string; address?: string }) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
@ -73,19 +96,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
localStorage.removeItem('almeira_user');
|
||||
};
|
||||
|
||||
const openAuthModal = () => setIsAuthModalOpen(true);
|
||||
const closeAuthModal = () => setIsAuthModalOpen(false);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthModalOpen,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
openAuthModal: () => setIsAuthModalOpen(true),
|
||||
closeAuthModal: () => setIsAuthModalOpen(false),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -2334,17 +2334,42 @@ class DataService {
|
||||
return this.products.length < initialLen;
|
||||
}
|
||||
|
||||
getSuppliers(): Supplier[] {
|
||||
return this.suppliers;
|
||||
getSuppliers(includeInactive: boolean = true): Supplier[] {
|
||||
if (includeInactive) return this.suppliers;
|
||||
return this.suppliers.filter((s) => s.isActive !== false);
|
||||
}
|
||||
|
||||
getSupplierById(id: string): Supplier | undefined {
|
||||
return this.suppliers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
addSupplier(supplier: Supplier): Supplier {
|
||||
this.suppliers.push(supplier);
|
||||
return supplier;
|
||||
addSupplier(supplier: Partial<Supplier>): Supplier {
|
||||
const newSupplier: Supplier = {
|
||||
id: supplier.id || `sup-${Date.now()}`,
|
||||
name: supplier.name || 'Nuevo Proveedor',
|
||||
phone: supplier.phone || '573167455877',
|
||||
contactPerson: supplier.contactPerson || '',
|
||||
address: supplier.address || '',
|
||||
notes: supplier.notes || '',
|
||||
isActive: supplier.isActive !== undefined ? supplier.isActive : true,
|
||||
};
|
||||
this.suppliers.push(newSupplier);
|
||||
return newSupplier;
|
||||
}
|
||||
|
||||
updateSupplier(id: string, updates: Partial<Supplier>): Supplier | null {
|
||||
const idx = this.suppliers.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.suppliers[idx] = { ...this.suppliers[idx], ...updates };
|
||||
return this.suppliers[idx];
|
||||
}
|
||||
|
||||
deleteSupplier(id: string): boolean {
|
||||
// Soft delete (desactivación segura para preservar 100% el historial de pedidos y productos)
|
||||
const supplier = this.suppliers.find((s) => s.id === id);
|
||||
if (!supplier) return false;
|
||||
supplier.isActive = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getCities(): City[] {
|
||||
@ -2382,6 +2407,10 @@ class DataService {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getOrderById(id: string): Order | undefined {
|
||||
return this.orders.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
addOrder(order: Order): Order {
|
||||
this.orders.unshift(order);
|
||||
return order;
|
||||
@ -2389,7 +2418,7 @@ class DataService {
|
||||
|
||||
createOrder(orderData: Partial<Order>): Order {
|
||||
const newOrder: Order = {
|
||||
id: `ord-${Date.now()}`,
|
||||
id: orderData.id || `ord-${Date.now()}`,
|
||||
customerName: orderData.customerName || 'Cliente',
|
||||
customerPhone: orderData.customerPhone || '',
|
||||
address: orderData.address || '',
|
||||
@ -2399,18 +2428,43 @@ class DataService {
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shippingCost || 0,
|
||||
totalAmount: orderData.totalAmount || 0,
|
||||
depositAmount: orderData.depositAmount || 10000,
|
||||
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000),
|
||||
depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
|
||||
balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
|
||||
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
|
||||
paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
|
||||
status: 'pending',
|
||||
status: orderData.status || 'pending',
|
||||
notes: orderData.notes,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: orderData.createdAt || new Date().toISOString(),
|
||||
};
|
||||
this.orders.unshift(newOrder);
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
updateOrder(id: string, updates: Partial<Order>): Order | null {
|
||||
const idx = this.orders.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const current = this.orders[idx];
|
||||
const total = updates.totalAmount !== undefined ? updates.totalAmount : current.totalAmount;
|
||||
const deposit = updates.depositAmount !== undefined ? updates.depositAmount : current.depositAmount;
|
||||
const balanceDue = updates.balanceDue !== undefined ? updates.balanceDue : (total - deposit);
|
||||
|
||||
this.orders[idx] = {
|
||||
...current,
|
||||
...updates,
|
||||
totalAmount: total,
|
||||
depositAmount: deposit,
|
||||
balanceDue,
|
||||
};
|
||||
return this.orders[idx];
|
||||
}
|
||||
|
||||
deleteOrder(id: string): boolean {
|
||||
const initialLen = this.orders.length;
|
||||
this.orders = this.orders.filter((o) => o.id !== id);
|
||||
return this.orders.length < initialLen;
|
||||
}
|
||||
|
||||
updateOrderStatus(orderId: string, status: Order['status']): Order | null {
|
||||
const order = this.orders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
|
||||
@ -6,30 +6,28 @@ const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
|
||||
|
||||
/**
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1200,
|
||||
presence: 'composing',
|
||||
},
|
||||
textMessage: {
|
||||
text: message,
|
||||
},
|
||||
delay: 1200,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -40,32 +38,31 @@ export async function sendWhatsAppMessage(toPhone: string, message: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1500,
|
||||
presence: 'composing',
|
||||
},
|
||||
mediaMessage: {
|
||||
mediatype: 'image',
|
||||
caption,
|
||||
mimetype: 'image/jpeg',
|
||||
caption: caption,
|
||||
media: imageUrl,
|
||||
},
|
||||
delay: 1500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 25000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -83,7 +80,7 @@ export function buildOrderConfirmationMessage(order: any): string {
|
||||
.map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN ALMEIRA STORE!* 🎉
|
||||
📌 *No. de Orden:* #${order.id}
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@ -23,6 +24,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
||||
{ href: '/admin/proveedores', label: 'Proveedores & Bodegas', icon: Building2, badge: 'Activos/Inactivos' },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
|
||||
@ -20,25 +20,90 @@ import {
|
||||
Sparkles,
|
||||
Compass,
|
||||
Check,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Plus,
|
||||
X,
|
||||
CreditCard,
|
||||
User,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { Order, OrderItem } from '@/types';
|
||||
import { Order, OrderItem, Supplier, City } from '@/types';
|
||||
import { formatCOP } from '@/lib/pricing-engine';
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'individual' | 'suppliers' | 'routes'>('individual');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Edit Order Modal State
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingOrder, setEditingOrder] = useState<Order | null>(null);
|
||||
const [editCustomerName, setEditCustomerName] = useState('');
|
||||
const [editCustomerPhone, setEditCustomerPhone] = useState('');
|
||||
const [editAddress, setEditAddress] = useState('');
|
||||
const [editCityId, setEditCityId] = useState('city-cali');
|
||||
const [editCityName, setEditCityName] = useState('Cali');
|
||||
const [editStatus, setEditStatus] = useState<Order['status']>('pending');
|
||||
const [editPaymentMethodName, setEditPaymentMethodName] = useState('');
|
||||
const [editTotalAmount, setEditTotalAmount] = useState(0);
|
||||
const [editDepositAmount, setEditDepositAmount] = useState(10000);
|
||||
const [editShippingCost, setEditShippingCost] = useState(0);
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
const [editItems, setEditItems] = useState<OrderItem[]>([]);
|
||||
const [isSavingOrder, setIsSavingOrder] = useState(false);
|
||||
|
||||
// Delete Order Confirmation Modal State
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [orderToDelete, setOrderToDelete] = useState<Order | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/orders')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setOrders(d.orders))
|
||||
.catch(() => {});
|
||||
fetchOrders();
|
||||
fetchSuppliers();
|
||||
fetchCities();
|
||||
}, []);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/orders');
|
||||
const d = await r.json();
|
||||
if (d.success && d.orders) {
|
||||
setOrders(d.orders);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading orders:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/suppliers?all=true');
|
||||
const d = await r.json();
|
||||
if (d.success && d.suppliers) {
|
||||
setSuppliers(d.suppliers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading suppliers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCities = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/cities');
|
||||
const d = await r.json();
|
||||
if (d.success && d.cities) {
|
||||
setCities(d.cities);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading cities:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (orderId: string, newStatus: Order['status']) => {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === orderId ? { ...o, status: newStatus } : o))
|
||||
@ -51,14 +116,158 @@ export default function AdminOrdersPage() {
|
||||
body: JSON.stringify({ id: orderId, status: newStatus }),
|
||||
});
|
||||
setToastMessage(`Estado de la orden #${orderId} actualizado a "${newStatus}".`);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Open Edit Order Modal
|
||||
const handleOpenEditModal = (order: Order) => {
|
||||
setEditingOrder(order);
|
||||
setEditCustomerName(order.customerName);
|
||||
setEditCustomerPhone(order.customerPhone);
|
||||
setEditAddress(order.address);
|
||||
setEditCityId(order.cityId);
|
||||
setEditCityName(order.cityName);
|
||||
setEditStatus(order.status);
|
||||
setEditPaymentMethodName(order.paymentMethodName || 'Contraentrega');
|
||||
setEditTotalAmount(order.totalAmount);
|
||||
setEditDepositAmount(order.depositAmount);
|
||||
setEditShippingCost(order.shippingCost || 0);
|
||||
setEditNotes(order.notes || '');
|
||||
setEditItems([...order.items]);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
// Handle Edit Item in Modal
|
||||
const handleItemChange = (index: number, field: keyof OrderItem, value: any) => {
|
||||
const updated = [...editItems];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
|
||||
// If changing supplierId, auto update supplierName and supplierPhone
|
||||
if (field === 'supplierName') {
|
||||
const foundSup = suppliers.find((s) => s.name === value || s.id === value);
|
||||
if (foundSup) {
|
||||
updated[index].supplierName = foundSup.name;
|
||||
updated[index].supplierPhone = foundSup.phone;
|
||||
}
|
||||
}
|
||||
|
||||
setEditItems(updated);
|
||||
|
||||
// Auto update total amount if prices/quantities change
|
||||
if (field === 'price' || field === 'quantity') {
|
||||
const newSubtotal = updated.reduce((acc, curr) => acc + (curr.price || 0) * (curr.quantity || 1), 0);
|
||||
setEditTotalAmount(newSubtotal + editShippingCost);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItemFromEdit = (index: number) => {
|
||||
const updated = editItems.filter((_, idx) => idx !== index);
|
||||
setEditItems(updated);
|
||||
const newSubtotal = updated.reduce((acc, curr) => acc + (curr.price || 0) * (curr.quantity || 1), 0);
|
||||
setEditTotalAmount(newSubtotal + editShippingCost);
|
||||
};
|
||||
|
||||
const handleAddItemToEdit = () => {
|
||||
const defaultSup = suppliers[0] || { name: 'DIFUSIÓN JOVIC STORE', phone: '573167455877' };
|
||||
const newItem: OrderItem = {
|
||||
productId: `prod-manual-${Date.now()}`,
|
||||
productName: 'Nuevo Modelo / Zapatilla',
|
||||
brand: 'Nike',
|
||||
size: '39',
|
||||
quantity: 1,
|
||||
price: 125000,
|
||||
supplierPrice: 90000,
|
||||
supplierName: defaultSup.name,
|
||||
supplierPhone: defaultSup.phone,
|
||||
image: 'https://images.unsplash.com/photo-1595950653106-6c9ebd614d3a?auto=format&fit=crop&w=800&q=80',
|
||||
};
|
||||
const updated = [...editItems, newItem];
|
||||
setEditItems(updated);
|
||||
const newSubtotal = updated.reduce((acc, curr) => acc + (curr.price || 0) * (curr.quantity || 1), 0);
|
||||
setEditTotalAmount(newSubtotal + editShippingCost);
|
||||
};
|
||||
|
||||
// Save Order Updates
|
||||
const handleSaveOrderEdits = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingOrder) return;
|
||||
setIsSavingOrder(true);
|
||||
|
||||
const balanceDue = editTotalAmount - editDepositAmount;
|
||||
|
||||
const payload: Partial<Order> = {
|
||||
id: editingOrder.id,
|
||||
customerName: editCustomerName,
|
||||
customerPhone: editCustomerPhone,
|
||||
address: editAddress,
|
||||
cityId: editCityId,
|
||||
cityName: editCityName,
|
||||
status: editStatus,
|
||||
paymentMethodName: editPaymentMethodName,
|
||||
totalAmount: editTotalAmount,
|
||||
depositAmount: editDepositAmount,
|
||||
shippingCost: editShippingCost,
|
||||
balanceDue: balanceDue > 0 ? balanceDue : 0,
|
||||
items: editItems,
|
||||
notes: editNotes,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === editingOrder.id ? { ...o, ...payload } as Order : o))
|
||||
);
|
||||
setToastMessage(`✅ Orden #${editingOrder.id} modificada y guardada correctamente.`);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
setIsEditModalOpen(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving order edits:', err);
|
||||
} finally {
|
||||
setIsSavingOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete Order
|
||||
const handleOpenDeleteModal = (order: Order) => {
|
||||
setOrderToDelete(order);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDeleteOrder = async () => {
|
||||
if (!orderToDelete) return;
|
||||
const targetId = orderToDelete.id;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/orders?id=${targetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setOrders((prev) => prev.filter((o) => o.id !== targetId));
|
||||
setToastMessage(`🗑️ Orden #${targetId} eliminada correctamente.`);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting order:', err);
|
||||
} finally {
|
||||
setIsDeleteModalOpen(false);
|
||||
setOrderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Notificar separación individual a proveedor
|
||||
const handleNotifySingleSupplier = (order: Order, item: OrderItem) => {
|
||||
const supplierPhone = item.supplierPhone || '573134848203';
|
||||
const supplierPhone = item.supplierPhone || '573167455877';
|
||||
const supplierName = item.supplierName || 'Proveedor Mayorista';
|
||||
const supplierCost = item.supplierPrice ? formatCOP(item.supplierPrice) : 'Precio Mayorista';
|
||||
|
||||
@ -97,25 +306,21 @@ export default function AdminOrdersPage() {
|
||||
setToastMessage(`📲 Manifiesto de ${totalPairs} pares enviado a ${supplierName}.`);
|
||||
};
|
||||
|
||||
// 3. Abrir en Waze
|
||||
const handleOpenWaze = (address: string, city: string) => {
|
||||
const query = encodeURIComponent(`${address}, ${city}, Colombia`);
|
||||
// Waze Universal Deep Link
|
||||
const wazeUrl = `https://waze.com/ul?q=${query}&navigate=yes`;
|
||||
window.open(wazeUrl, '_blank');
|
||||
};
|
||||
|
||||
// 4. Abrir en Google Maps
|
||||
const handleOpenGoogleMaps = (address: string, city: string) => {
|
||||
const query = encodeURIComponent(`${address}, ${city}, Colombia`);
|
||||
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${query}`;
|
||||
window.open(mapsUrl, '_blank');
|
||||
};
|
||||
|
||||
// 5. Avisar al cliente que el mensajero va en camino
|
||||
const handleNotifyCustomerOnTheWay = (order: Order) => {
|
||||
const cleanPhone = order.customerPhone.startsWith('57') ? order.customerPhone : `57${order.customerPhone}`;
|
||||
const text = `👋 ¡Hola *${order.customerName}*!\n\n🛵 Tu pedido de *DripZone* ya va *EN CAMINO* con nuestro mensajero.\n\n📍 *Dirección de Entrega:* ${order.address}\n🏙️ *Ciudad:* ${order.cityName}\n💵 *Saldo a Pagar Contraentrega:* *${formatCOP(order.balanceDue)}*\n💳 *Medio de Pago:* Efectivo o Transferencia Nequi\n\nPor favor estar atento a la portería o celular. ¡Gracias por tu compra! 🔥`;
|
||||
const text = `👋 ¡Hola *${order.customerName}*!\n\n🛵 Tu pedido ya va *EN CAMINO* con nuestro mensajero.\n\n📍 *Dirección de Entrega:* ${order.address}\n🏙️ *Ciudad:* ${order.cityName}\n💵 *Saldo a Pagar Contraentrega:* *${formatCOP(order.balanceDue)}*\n💳 *Medio de Pago:* Efectivo o Transferencia Nequi\n\nPor favor estar atento a la portería o celular. ¡Gracias por tu compra! 🔥`;
|
||||
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${encodeURIComponent(text)}`, '_blank');
|
||||
handleStatusChange(order.id, 'dispatched');
|
||||
@ -138,7 +343,6 @@ export default function AdminOrdersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrado de pedidos
|
||||
const filteredOrders = orders.filter((o) => {
|
||||
const matchesSearch =
|
||||
o.customerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
@ -152,15 +356,19 @@ export default function AdminOrdersPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Agrupación por Proveedor
|
||||
// Consolidado por Proveedor
|
||||
const supplierGroups = orders.reduce((acc, order) => {
|
||||
order.items.forEach((item) => {
|
||||
const suppName = item.supplierName || 'DIFUSION JOVIC STORE';
|
||||
const suppPhone = item.supplierPhone || '573134848203';
|
||||
const suppName = item.supplierName || '💎 DIFUSIÓN JOVIC STORE COLOMBIA';
|
||||
const suppPhone = item.supplierPhone || '573167455877';
|
||||
const matchingSupplier = suppliers.find((s) => s.name.toLowerCase() === suppName.toLowerCase());
|
||||
const isActiveSup = matchingSupplier ? matchingSupplier.isActive !== false : true;
|
||||
|
||||
if (!acc[suppName]) {
|
||||
acc[suppName] = {
|
||||
name: suppName,
|
||||
phone: suppPhone,
|
||||
isActive: isActiveSup,
|
||||
entries: [],
|
||||
totalPairs: 0,
|
||||
totalCost: 0,
|
||||
@ -171,9 +379,8 @@ export default function AdminOrdersPage() {
|
||||
acc[suppName].totalCost += (item.supplierPrice || 0) * item.quantity;
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, { name: string; phone: string; entries: { order: Order; item: OrderItem }[]; totalPairs: number; totalCost: number }>);
|
||||
}, {} as Record<string, { name: string; phone: string; isActive: boolean; entries: { order: Order; item: OrderItem }[]; totalPairs: number; totalCost: number }>);
|
||||
|
||||
// Agrupación de Pendientes por Entregar (Zonas / Direcciones)
|
||||
const pendingDeliveries = orders.filter((o) => o.status !== 'delivered' && o.status !== 'cancelled');
|
||||
const totalBalanceToCollect = pendingDeliveries.reduce((acc, curr) => acc + curr.balanceDue, 0);
|
||||
|
||||
@ -187,13 +394,13 @@ export default function AdminOrdersPage() {
|
||||
<span>Centro Logístico: Pedidos, Proveedores & Rutas Waze</span>
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Consolidación automática para <strong>separación con proveedores</strong> y <strong>hoja de ruta para mensajeros con navegación Waze en 1 clic</strong>.
|
||||
Gestión completa de pedidos (<strong>editar datos, cambiar tallas, precios o eliminar</strong>), consolidación con proveedores y <strong>navegación Waze en 1 clic</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{toastMessage && (
|
||||
<div className="bg-purple-950/80 border border-purple-500 text-purple-300 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
|
||||
<div className="bg-purple-950/80 border border-purple-500 text-purple-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between animate-in fade-in shadow-md">
|
||||
<span>{toastMessage}</span>
|
||||
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
@ -207,7 +414,7 @@ export default function AdminOrdersPage() {
|
||||
onClick={() => setActiveTab('individual')}
|
||||
className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
|
||||
activeTab === 'individual'
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-600/30'
|
||||
? 'bg-gradient-to-r from-purple-600 to-fuchsia-600 text-white shadow-lg shadow-purple-500/30'
|
||||
: 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
@ -219,11 +426,11 @@ export default function AdminOrdersPage() {
|
||||
onClick={() => setActiveTab('suppliers')}
|
||||
className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
|
||||
activeTab === 'suppliers'
|
||||
? 'bg-purple-600 text-white shadow-lg shadow-purple-600/30'
|
||||
? 'bg-gradient-to-r from-purple-600 to-fuchsia-600 text-white shadow-lg shadow-purple-500/30'
|
||||
: 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-fuchsia-300" />
|
||||
<Building2 className="w-4 h-4 text-purple-300" />
|
||||
<span>2. Consolidado por Proveedor ({Object.keys(supplierGroups).length})</span>
|
||||
</button>
|
||||
|
||||
@ -231,11 +438,11 @@ export default function AdminOrdersPage() {
|
||||
onClick={() => setActiveTab('routes')}
|
||||
className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
|
||||
activeTab === 'routes'
|
||||
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg shadow-pink-600/30'
|
||||
? 'bg-gradient-to-r from-purple-600 to-emerald-500 text-white shadow-lg shadow-emerald-500/30'
|
||||
: 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Compass className="w-4 h-4 text-cyan-300" />
|
||||
<Compass className="w-4 h-4 text-emerald-300" />
|
||||
<span>3. Rutas de Entrega & Waze ({pendingDeliveries.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -243,7 +450,6 @@ export default function AdminOrdersPage() {
|
||||
{/* TAB 1: INDIVIDUAL ORDERS LIST */}
|
||||
{activeTab === 'individual' && (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
|
||||
{['all', 'pending', 'deposit_received', 'supplier_notified', 'dispatched', 'delivered'].map((st) => (
|
||||
@ -270,7 +476,6 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders Cards Grid */}
|
||||
<div className="space-y-4">
|
||||
{filteredOrders.length === 0 ? (
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500">
|
||||
@ -282,7 +487,7 @@ export default function AdminOrdersPage() {
|
||||
key={order.id}
|
||||
className="bg-neutral-900 border border-neutral-800 hover:border-purple-500/40 rounded-3xl p-5 shadow-xl transition-all space-y-4"
|
||||
>
|
||||
{/* Top Bar: Order ID, Status, Actions */}
|
||||
{/* Top bar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-neutral-800 pb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="font-black text-white text-base">Orden #{order.id}</span>
|
||||
@ -290,9 +495,24 @@ export default function AdminOrdersPage() {
|
||||
<span className="text-[11px] text-neutral-500 font-mono">{order.createdAt}</span>
|
||||
</div>
|
||||
|
||||
{/* Status Dropdown */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-400 font-bold">Estado:</span>
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(order)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-purple-300 font-bold text-xs px-3 py-1.5 rounded-xl border border-neutral-700 flex items-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||
title="Modificar o editar datos de este pedido"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>Editar Pedido</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenDeleteModal(order)}
|
||||
className="bg-red-500/10 hover:bg-red-500/20 text-red-400 font-bold text-xs px-2.5 py-1.5 rounded-xl border border-red-500/30 flex items-center gap-1 cursor-pointer transition-all active:scale-95"
|
||||
title="Eliminar este pedido"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<select
|
||||
value={order.status}
|
||||
onChange={(e) => handleStatusChange(order.id, e.target.value as any)}
|
||||
@ -308,9 +528,7 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info & Financials */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
|
||||
{/* Customer */}
|
||||
<div className="space-y-1 bg-neutral-950/60 p-3 rounded-2xl border border-neutral-800/80">
|
||||
<p className="font-bold text-neutral-400 uppercase text-[10px]">Cliente & Destino</p>
|
||||
<p className="text-white font-bold text-sm">{order.customerName}</p>
|
||||
@ -321,9 +539,13 @@ export default function AdminOrdersPage() {
|
||||
<MapPin className="w-3 h-3 shrink-0 text-purple-400 mt-0.5" />
|
||||
<span>{order.address} ({order.cityName})</span>
|
||||
</p>
|
||||
{order.notes && (
|
||||
<p className="text-amber-300 text-[11px] italic bg-amber-500/10 p-1.5 rounded-lg border border-amber-500/20 mt-1">
|
||||
Nota: {order.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Financial Balance */}
|
||||
<div className="space-y-1 bg-neutral-950/60 p-3 rounded-2xl border border-neutral-800/80">
|
||||
<p className="font-bold text-neutral-400 uppercase text-[10px]">Resumen de Cobro</p>
|
||||
<div className="flex justify-between text-neutral-300">
|
||||
@ -332,7 +554,7 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
<div className="flex justify-between text-neutral-300">
|
||||
<span>Anticipo / Separación:</span>
|
||||
<strong className="text-blue-400">-{formatCOP(order.depositAmount)}</strong>
|
||||
<strong className="text-purple-400">-{formatCOP(order.depositAmount)}</strong>
|
||||
</div>
|
||||
<div className="flex justify-between text-neutral-300 pt-1 border-t border-neutral-800 font-bold">
|
||||
<span className="text-purple-300">Saldo Contraentrega:</span>
|
||||
@ -340,11 +562,10 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Dispatch Actions */}
|
||||
<div className="space-y-2 bg-neutral-950/60 p-3 rounded-2xl border border-neutral-800/80 flex flex-col justify-center">
|
||||
<button
|
||||
onClick={() => handleOpenWaze(order.address, order.cityName)}
|
||||
className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold py-1.5 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all text-xs"
|
||||
className="w-full bg-purple-600 hover:bg-purple-500 text-white font-bold py-1.5 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all text-xs"
|
||||
>
|
||||
<Compass className="w-3.5 h-3.5" />
|
||||
<span>Navegar con Waze</span>
|
||||
@ -360,7 +581,6 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items breakdown with Supplier notification button */}
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="font-bold text-neutral-400 uppercase text-[10px]">Pares en esta orden:</p>
|
||||
<div className="space-y-2">
|
||||
@ -374,14 +594,14 @@ export default function AdminOrdersPage() {
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">{item.productName}</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
Talla: <strong className="text-purple-400">{item.size}</strong> | Cantidad: <strong>{item.quantity}</strong> | Proveedor: <strong className="text-purple-300">{item.supplierName || 'Bodega Principal'}</strong>
|
||||
Talla: <strong className="text-purple-400">{item.size}</strong> | Cantidad: <strong>{item.quantity}</strong> | Costo: <strong className="font-mono text-emerald-300">{formatCOP(item.supplierPrice || 0)}</strong> | Proveedor: <strong className="text-purple-300">{item.supplierName || 'Bodega Principal'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleNotifySingleSupplier(order, item)}
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-bold text-xs px-3.5 py-2 rounded-xl flex items-center space-x-1.5 shadow cursor-pointer shrink-0 self-end sm:self-auto"
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold text-xs px-3.5 py-2 rounded-xl flex items-center space-x-1.5 shadow cursor-pointer shrink-0 self-end sm:self-auto"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>Separar con Proveedor</span>
|
||||
@ -406,7 +626,7 @@ export default function AdminOrdersPage() {
|
||||
<span>Consolidado Automático de Compras & Separaciones</span>
|
||||
</div>
|
||||
<p>
|
||||
Aquí se agrupan todos los pares pendientes clasificados por cada <strong>Proveedor Mayorista</strong>. Puedes enviar el <strong>manifiesto consolidado en 1 solo mensaje de WhatsApp</strong> para que el proveedor prepare y empaque todos los pedidos de la jornada juntos.
|
||||
Agrupación automática por <strong>Proveedor Mayorista</strong>. Si un proveedor ha sido desactivado, sus pedidos y cuentas permanecen 100% calculados e intactos en el historial.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -416,12 +636,18 @@ export default function AdminOrdersPage() {
|
||||
key={group.name}
|
||||
className="bg-neutral-900 border border-neutral-800 rounded-3xl p-6 shadow-xl space-y-4"
|
||||
>
|
||||
{/* Supplier Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-neutral-800 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-purple-500/20 text-purple-300 border border-purple-500/40 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wider">
|
||||
Proveedor Mayorista
|
||||
</span>
|
||||
{!group.isActive && (
|
||||
<span className="bg-amber-500/20 text-amber-300 border border-amber-500/40 text-[10px] font-bold px-2 py-0.5 rounded-full uppercase">
|
||||
⚪ Inactivo / Histórico
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-black text-white mt-1">{group.name}</h3>
|
||||
<p className="text-xs text-neutral-400 flex items-center gap-1 mt-0.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400" />
|
||||
@ -441,7 +667,6 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Table for this supplier */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs text-neutral-300">
|
||||
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px]">
|
||||
@ -474,11 +699,10 @@ export default function AdminOrdersPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Send Batch WhatsApp Manifest Button */}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => handleSendSupplierBatchManifest(group.name, group.phone, group.entries)}
|
||||
className="bg-gradient-to-r from-purple-600 via-fuchsia-600 to-pink-600 hover:opacity-95 text-white font-black text-xs px-5 py-3 rounded-2xl shadow-lg flex items-center gap-2 cursor-pointer transition-all active:scale-95"
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-black text-xs px-5 py-3 rounded-2xl shadow-lg flex items-center gap-2 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>Enviar Manifiesto de {group.totalPairs} Pares por WhatsApp a {group.name}</span>
|
||||
@ -493,15 +717,14 @@ export default function AdminOrdersPage() {
|
||||
{/* TAB 3: RUTAS DE ENTREGA ORGANIZADAS POR DIRECCIONES & WAZE */}
|
||||
{activeTab === 'routes' && (
|
||||
<div className="space-y-6 animate-in fade-in">
|
||||
{/* KPI Route Header */}
|
||||
<div className="bg-gradient-to-r from-neutral-900 via-neutral-900 to-purple-950/60 border border-purple-500/30 rounded-3xl p-6 shadow-xl flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 text-cyan-300 font-bold text-sm">
|
||||
<Compass className="w-5 h-5 text-cyan-400" />
|
||||
<div className="flex items-center space-x-2 text-purple-300 font-bold text-sm">
|
||||
<Compass className="w-5 h-5 text-purple-400" />
|
||||
<span>Hoja de Ruta & Navegación GPS (Waze / Google Maps)</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-300 mt-1 max-w-xl">
|
||||
Entregas contraentrega secuenciadas por zona geográfica. El mensajero puede presionar <strong>"Abrir en Waze"</strong> para iniciar la navegación instantánea a cada portería/casa.
|
||||
Entregas contraentrega secuenciadas por zona geográfica. Presiona <strong>"Abrir en Waze"</strong> para iniciar la navegación instantánea.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -518,7 +741,6 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Stops Sequential List */}
|
||||
<div className="space-y-3">
|
||||
{pendingDeliveries.length === 0 ? (
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500">
|
||||
@ -528,12 +750,10 @@ export default function AdminOrdersPage() {
|
||||
pendingDeliveries.map((delivery, index) => (
|
||||
<div
|
||||
key={delivery.id}
|
||||
className="bg-neutral-900 border border-neutral-800 hover:border-cyan-500/40 rounded-3xl p-5 shadow-lg transition-all flex flex-col lg:flex-row lg:items-center justify-between gap-4"
|
||||
className="bg-neutral-900 border border-neutral-800 hover:border-purple-500/40 rounded-3xl p-5 shadow-lg transition-all flex flex-col lg:flex-row lg:items-center justify-between gap-4"
|
||||
>
|
||||
{/* Left: Sequence Number & Stop Details */}
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Stop Number Badge */}
|
||||
<div className="w-12 h-12 rounded-2xl bg-cyan-500/20 border border-cyan-500/40 flex flex-col items-center justify-center text-cyan-300 font-black shrink-0 shadow-sm">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/20 border border-purple-500/40 flex flex-col items-center justify-center text-purple-300 font-black shrink-0 shadow-sm">
|
||||
<span className="text-[10px] uppercase font-bold text-neutral-400 leading-none">Parada</span>
|
||||
<span className="text-lg leading-tight">#{index + 1}</span>
|
||||
</div>
|
||||
@ -545,9 +765,8 @@ export default function AdminOrdersPage() {
|
||||
{getStatusBadge(delivery.status)}
|
||||
</div>
|
||||
|
||||
{/* Exact Address Highlight */}
|
||||
<p className="text-xs font-bold text-white flex items-center gap-1.5 bg-neutral-950 px-3 py-1 rounded-xl border border-neutral-800 w-fit">
|
||||
<MapPin className="w-4 h-4 text-cyan-400 shrink-0" />
|
||||
<MapPin className="w-4 h-4 text-purple-400 shrink-0" />
|
||||
<span>{delivery.address} ({delivery.cityName})</span>
|
||||
</p>
|
||||
|
||||
@ -569,25 +788,29 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Balance & 1-Click Waze / WhatsApp Actions */}
|
||||
<div className="flex items-center gap-3 self-end lg:self-auto shrink-0">
|
||||
{/* Cash to collect badge */}
|
||||
<div className="bg-neutral-950 border border-neutral-800 px-3.5 py-2 rounded-2xl text-right">
|
||||
<p className="text-[10px] text-neutral-400 font-bold uppercase">Cobrar Contraentrega</p>
|
||||
<p className="text-base font-black text-emerald-400">{formatCOP(delivery.balanceDue)}</p>
|
||||
</div>
|
||||
|
||||
{/* Waze GPS Button */}
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(delivery)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-purple-300 font-bold text-xs p-3 rounded-2xl shadow cursor-pointer transition-all"
|
||||
title="Editar datos de este pedido"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenWaze(delivery.address, delivery.cityName)}
|
||||
className="bg-cyan-600 hover:bg-cyan-500 text-white font-black text-xs px-4 py-3 rounded-2xl shadow-lg flex items-center gap-1.5 cursor-pointer active:scale-95 transition-all"
|
||||
className="bg-purple-600 hover:bg-purple-500 text-white font-black text-xs px-4 py-3 rounded-2xl shadow-lg flex items-center gap-1.5 cursor-pointer active:scale-95 transition-all"
|
||||
title="Abrir ubicación directamente en Waze"
|
||||
>
|
||||
<Compass className="w-4 h-4" />
|
||||
<span>Waze</span>
|
||||
</button>
|
||||
|
||||
{/* Google Maps GPS Button */}
|
||||
<button
|
||||
onClick={() => handleOpenGoogleMaps(delivery.address, delivery.cityName)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-white font-bold text-xs p-3 rounded-2xl shadow cursor-pointer transition-all"
|
||||
@ -596,7 +819,6 @@ export default function AdminOrdersPage() {
|
||||
<Navigation className="w-4 h-4 text-emerald-400" />
|
||||
</button>
|
||||
|
||||
{/* WhatsApp Alert */}
|
||||
<button
|
||||
onClick={() => handleNotifyCustomerOnTheWay(delivery)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3.5 py-3 rounded-2xl shadow flex items-center gap-1.5 cursor-pointer active:scale-95 transition-all"
|
||||
@ -612,6 +834,297 @@ export default function AdminOrdersPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: EDITAR / MODIFICAR PEDIDO */}
|
||||
{isEditModalOpen && editingOrder && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in overflow-y-auto">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-2xl w-full p-6 space-y-4 shadow-2xl my-8 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Edit3 className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="font-black text-white text-base">Modificar Orden #{editingOrder.id}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="text-neutral-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveOrderEdits} className="space-y-4 text-xs">
|
||||
{/* Customer details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 bg-neutral-950/60 p-3.5 rounded-2xl border border-neutral-800">
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Nombre del Cliente *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editCustomerName}
|
||||
onChange={(e) => setEditCustomerName(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Celular WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={editCustomerPhone}
|
||||
onChange={(e) => setEditCustomerPhone(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-neutral-400 font-bold mb-1">Dirección de Entrega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editAddress}
|
||||
onChange={(e) => setEditAddress(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ciudad Destino</label>
|
||||
<select
|
||||
value={editCityName}
|
||||
onChange={(e) => {
|
||||
const cityName = e.target.value;
|
||||
setEditCityName(cityName);
|
||||
const foundCity = cities.find((c) => c.name === cityName);
|
||||
if (foundCity) setEditCityId(foundCity.id);
|
||||
}}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-semibold"
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.id} value={c.name}>
|
||||
{c.name} ({c.department})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-purple-400 font-bold mb-1">Estado de la Orden</label>
|
||||
<select
|
||||
value={editStatus}
|
||||
onChange={(e) => setEditStatus(e.target.value as any)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white outline-hidden font-bold"
|
||||
>
|
||||
<option value="pending">⏳ Pendiente Confirmar</option>
|
||||
<option value="deposit_received">💳 Separación $10k Pagada</option>
|
||||
<option value="supplier_notified">🏢 Proveedor Notificado</option>
|
||||
<option value="dispatched">🛵 En Ruta con Mensajero</option>
|
||||
<option value="delivered">✅ Entregado & Cobrado</option>
|
||||
<option value="cancelled">❌ Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Management */}
|
||||
<div className="space-y-2 bg-neutral-950/60 p-3.5 rounded-2xl border border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-bold text-white uppercase text-[11px]">Zapatillas & Proveedores en la Orden</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItemToEdit}
|
||||
className="text-[11px] text-purple-400 hover:text-purple-300 font-bold flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Agregar Par</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{editItems.map((item, idx) => (
|
||||
<div key={idx} className="bg-neutral-900 border border-neutral-800 p-3 rounded-xl space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-white text-xs">Par #{idx + 1}</span>
|
||||
{editItems.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItemFromEdit(idx)}
|
||||
className="text-red-400 hover:text-red-300 text-[10px] font-bold cursor-pointer"
|
||||
>
|
||||
Eliminar Par
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-[10px] text-neutral-400 font-bold">Modelo / Nombre</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.productName}
|
||||
onChange={(e) => handleItemChange(idx, 'productName', e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-purple-400 font-bold">Talla</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.size}
|
||||
onChange={(e) => handleItemChange(idx, 'size', e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-white font-mono font-bold outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-neutral-400 font-bold">Cantidad</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) => handleItemChange(idx, 'quantity', parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-white outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-neutral-400 font-bold">Precio Venta (COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.price}
|
||||
onChange={(e) => handleItemChange(idx, 'price', parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-white font-mono outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-emerald-400 font-bold">Costo Proveedor</label>
|
||||
<input
|
||||
type="number"
|
||||
value={item.supplierPrice || 0}
|
||||
onChange={(e) => handleItemChange(idx, 'supplierPrice', parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-emerald-300 font-mono outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-purple-300 font-bold">Proveedor Asignado</label>
|
||||
<select
|
||||
value={item.supplierName}
|
||||
onChange={(e) => handleItemChange(idx, 'supplierName', e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-2.5 py-1.5 text-white outline-hidden text-xs"
|
||||
>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name} {s.isActive === false ? '(⚪ Inactivo)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial calculations */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 bg-neutral-950/60 p-3.5 rounded-2xl border border-neutral-800">
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Total Venta ($ COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editTotalAmount}
|
||||
onChange={(e) => setEditTotalAmount(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-white font-mono font-bold outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-purple-400 font-bold mb-1">Anticipo Pagado ($ COP)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editDepositAmount}
|
||||
onChange={(e) => setEditDepositAmount(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-purple-300 font-mono font-bold outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Saldo Contraentrega</label>
|
||||
<div className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3 py-2 text-emerald-400 font-mono font-bold text-sm">
|
||||
{formatCOP(editTotalAmount - editDepositAmount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Notas Internas del Pedido</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
placeholder="Ej. Dejar en portería con el celador Juan..."
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2 text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditModalOpen(false)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingOrder}
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSavingOrder ? 'Guardando Cambios...' : 'Guardar Modificaciones'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MODAL: CONFIRMACIÓN DE ELIMINACIÓN DE PEDIDO */}
|
||||
{isDeleteModalOpen && orderToDelete && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-neutral-900 border border-red-500/30 rounded-3xl max-w-md w-full p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center space-x-3 text-red-400">
|
||||
<div className="w-10 h-10 rounded-2xl bg-red-500/20 border border-red-500/30 flex items-center justify-center">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<h3 className="font-black text-white text-base">¿Eliminar Pedido #{orderToDelete.id}?</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-300 leading-relaxed">
|
||||
Estás a punto de eliminar permanentemente la orden del cliente <strong>{orderToDelete.customerName}</strong> ({orderToDelete.cityName}) por valor de <strong>{formatCOP(orderToDelete.totalAmount)}</strong>.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold text-xs py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmDeleteOrder}
|
||||
className="bg-red-600 hover:bg-red-500 text-white font-bold text-xs py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
Sí, Eliminar Pedido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -50,6 +50,11 @@ export default function AdminProductsPage() {
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/suppliers?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && d.suppliers && setSuppliers(d.suppliers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
|
||||
593
dripzone-app/src/app/admin/proveedores/page.tsx
Normal file
593
dripzone-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Phone,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Edit2,
|
||||
Power,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
User,
|
||||
Info,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Supplier, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
|
||||
export default function AdminSuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [contactPerson, setContactPerson] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.suppliers) {
|
||||
setSuppliers(data.suppliers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching suppliers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/products?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.products) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching products:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditingSupplier(null);
|
||||
setName('');
|
||||
setPhone('57');
|
||||
setContactPerson('');
|
||||
setAddress('');
|
||||
setNotes('');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier);
|
||||
setName(supplier.name);
|
||||
setPhone(supplier.phone);
|
||||
setContactPerson(supplier.contactPerson || '');
|
||||
setAddress(supplier.address || '');
|
||||
setNotes(supplier.notes || '');
|
||||
setIsActive(supplier.isActive !== false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (supplier: Supplier) => {
|
||||
const nextStatus = !supplier.isActive;
|
||||
|
||||
// Optimistic update
|
||||
setSuppliers((prev) =>
|
||||
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: supplier.id,
|
||||
updates: { isActive: nextStatus },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(
|
||||
nextStatus
|
||||
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||
);
|
||||
setTimeout(() => setToast(null), 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling supplier active status:', e);
|
||||
fetchSuppliers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
// Edit existing
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSupplier.id,
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||
const text = encodeURIComponent(
|
||||
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de DripZone.`
|
||||
);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||
|
||||
const filteredSuppliers = suppliers.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.phone.includes(search) ||
|
||||
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||
return products.filter(
|
||||
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||
).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Building2 className="w-6 h-6 text-purple-400" />
|
||||
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Proveedor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="bg-purple-950/90 border border-purple-500 text-purple-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-neutral-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Total Proveedores</span>
|
||||
<Building2 className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||
<p className="text-[10px] text-neutral-400">Bodegas y mayoristas registrados</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Proveedores Activos</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Desactivados / Históricos</span>
|
||||
<XCircle className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||
<p className="text-[10px] text-neutral-400">Historial preservado 100% intacto</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-neutral-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Zapatillas Vinculadas</span>
|
||||
<Package className="w-4 h-4 text-fuchsia-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-fuchsia-300">{products.length}</p>
|
||||
<p className="text-[10px] text-neutral-400">Modelos en catálogo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner About Historical Safety */}
|
||||
<div className="bg-gradient-to-r from-neutral-900 via-neutral-900 to-purple-950/40 border border-purple-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-neutral-300 shadow-md">
|
||||
<Info className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-white">
|
||||
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({suppliers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||
: 'text-neutral-400 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Activos ({activeSuppliers.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||
: 'text-neutral-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSuppliers.length === 0 ? (
|
||||
<div className="col-span-full bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500 space-y-2">
|
||||
<Building2 className="w-12 h-12 text-neutral-600 mx-auto" />
|
||||
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSuppliers.map((supplier) => {
|
||||
const isSupActive = supplier.isActive !== false;
|
||||
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className={`bg-neutral-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||
isSupActive
|
||||
? 'border-neutral-800 hover:border-purple-500/40'
|
||||
: 'border-neutral-800/80 opacity-75 bg-neutral-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* Card Top: Status & Name */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||
isSupActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleActive(supplier)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isSupActive
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||
>
|
||||
<Power className="w-3 h-3" />
|
||||
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||
|
||||
{/* Details List */}
|
||||
<div className="space-y-1.5 text-xs pt-1">
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-neutral-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-neutral-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||
</p>
|
||||
|
||||
{supplier.address && (
|
||||
<p className="text-neutral-400 flex items-center gap-1.5 text-[11px]">
|
||||
<MapPin className="w-3.5 h-3.5 text-pink-400 shrink-0" />
|
||||
<span>{supplier.address}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supplier.notes && (
|
||||
<p className="text-neutral-400 italic text-[11px] bg-neutral-950 p-2 rounded-xl border border-neutral-800 mt-1">
|
||||
"{supplier.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Bottom: Metrics & Actions */}
|
||||
<div className="pt-3 border-t border-neutral-800 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-neutral-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5 text-fuchsia-400" />
|
||||
<span>Modelos asignados:</span>
|
||||
</span>
|
||||
<strong className="text-white bg-neutral-950 px-2 py-0.5 rounded-lg border border-neutral-800">
|
||||
{linkedProductsCount} pares
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenWhatsApp(supplier)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(supplier)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span>Modificar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Crear / Editar Proveedor */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-purple-400" />
|
||||
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-neutral-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 573167455877"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Persona de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Asesor Despachos"
|
||||
value={contactPerson}
|
||||
onChange={(e) => setContactPerson(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-neutral-950 p-3 rounded-2xl border border-neutral-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||
<p className="text-[10px] text-neutral-400">
|
||||
{isActive
|
||||
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-neutral-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateOrder(targetId, payload);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = dataService.deleteOrder(id);
|
||||
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
71
dripzone-app/src/app/api/suppliers/route.ts
Normal file
71
dripzone-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||
const suppliers = dataService.getSuppliers(includeInactive);
|
||||
return NextResponse.json({ success: true, suppliers });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !body.phone) {
|
||||
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||
}
|
||||
const newSupplier = dataService.addSupplier(body);
|
||||
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateSupplier(targetId, payload);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, supplier: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||
const success = dataService.deleteSupplier(id);
|
||||
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, Check } from 'lucide-react';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
interface MobileBottomDockProps {
|
||||
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
const [isInsideApp, setIsInsideApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isNativeApp =
|
||||
typeof window !== 'undefined' &&
|
||||
(!!(window as any).Capacitor?.isNativePlatform?.() ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'android' ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'ios' ||
|
||||
window.matchMedia?.('(display-mode: standalone)')?.matches ||
|
||||
(window.navigator as any)?.standalone === true);
|
||||
setIsInsideApp(isNativeApp);
|
||||
}, []);
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const text = encodeURIComponent(
|
||||
@ -84,6 +96,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
{!isInsideApp ? (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-purple-400 hover:text-purple-300"
|
||||
@ -93,6 +106,15 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
</div>
|
||||
<span className="text-[10px] font-bold">App Móvil</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-neutral-400 hover:text-white"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-[10px] font-bold">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -4,26 +4,43 @@ import React, { useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export const MobileSplashScreen: React.FC = () => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isFading, setIsFading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Verificar si ya se mostró en esta sesión
|
||||
// Solo mostrar el splashscreen dentro de la App Móvil nativa (Capacitor / PWA instalada)
|
||||
// En la página web / navegador regular NO debe mostrarse
|
||||
const isNativeApp =
|
||||
typeof window !== 'undefined' &&
|
||||
(!!(window as any).Capacitor?.isNativePlatform?.() ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'android' ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'ios' ||
|
||||
window.matchMedia?.('(display-mode: standalone)')?.matches ||
|
||||
(window.navigator as any)?.standalone === true);
|
||||
|
||||
if (!isNativeApp) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si ya se mostró en esta sesión de la app móvil
|
||||
const hasShown = sessionStorage.getItem('dripzone_splash_shown');
|
||||
if (hasShown) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Duración de 4.2 segundos para apreciar la imagen completa y eslogan
|
||||
setIsVisible(true);
|
||||
|
||||
// Duración para la app móvil
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setIsFading(true);
|
||||
}, 3800);
|
||||
}, 2800);
|
||||
|
||||
const removeTimer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
sessionStorage.setItem('dripzone_splash_shown', 'true');
|
||||
}, 4600);
|
||||
}, 3400);
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeTimer);
|
||||
|
||||
@ -2334,17 +2334,42 @@ class DataService {
|
||||
return this.products.length < initialLen;
|
||||
}
|
||||
|
||||
getSuppliers(): Supplier[] {
|
||||
return this.suppliers;
|
||||
getSuppliers(includeInactive: boolean = true): Supplier[] {
|
||||
if (includeInactive) return this.suppliers;
|
||||
return this.suppliers.filter((s) => s.isActive !== false);
|
||||
}
|
||||
|
||||
getSupplierById(id: string): Supplier | undefined {
|
||||
return this.suppliers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
addSupplier(supplier: Supplier): Supplier {
|
||||
this.suppliers.push(supplier);
|
||||
return supplier;
|
||||
addSupplier(supplier: Partial<Supplier>): Supplier {
|
||||
const newSupplier: Supplier = {
|
||||
id: supplier.id || `sup-${Date.now()}`,
|
||||
name: supplier.name || 'Nuevo Proveedor',
|
||||
phone: supplier.phone || '573167455877',
|
||||
contactPerson: supplier.contactPerson || '',
|
||||
address: supplier.address || '',
|
||||
notes: supplier.notes || '',
|
||||
isActive: supplier.isActive !== undefined ? supplier.isActive : true,
|
||||
};
|
||||
this.suppliers.push(newSupplier);
|
||||
return newSupplier;
|
||||
}
|
||||
|
||||
updateSupplier(id: string, updates: Partial<Supplier>): Supplier | null {
|
||||
const idx = this.suppliers.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.suppliers[idx] = { ...this.suppliers[idx], ...updates };
|
||||
return this.suppliers[idx];
|
||||
}
|
||||
|
||||
deleteSupplier(id: string): boolean {
|
||||
// Soft delete (desactivación segura para preservar 100% el historial de pedidos y productos)
|
||||
const supplier = this.suppliers.find((s) => s.id === id);
|
||||
if (!supplier) return false;
|
||||
supplier.isActive = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getCities(): City[] {
|
||||
@ -2382,6 +2407,10 @@ class DataService {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getOrderById(id: string): Order | undefined {
|
||||
return this.orders.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
addOrder(order: Order): Order {
|
||||
this.orders.unshift(order);
|
||||
return order;
|
||||
@ -2389,7 +2418,7 @@ class DataService {
|
||||
|
||||
createOrder(orderData: Partial<Order>): Order {
|
||||
const newOrder: Order = {
|
||||
id: `ord-${Date.now()}`,
|
||||
id: orderData.id || `ord-${Date.now()}`,
|
||||
customerName: orderData.customerName || 'Cliente',
|
||||
customerPhone: orderData.customerPhone || '',
|
||||
address: orderData.address || '',
|
||||
@ -2399,18 +2428,43 @@ class DataService {
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shippingCost || 0,
|
||||
totalAmount: orderData.totalAmount || 0,
|
||||
depositAmount: orderData.depositAmount || 10000,
|
||||
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000),
|
||||
depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
|
||||
balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
|
||||
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
|
||||
paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
|
||||
status: 'pending',
|
||||
status: orderData.status || 'pending',
|
||||
notes: orderData.notes,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: orderData.createdAt || new Date().toISOString(),
|
||||
};
|
||||
this.orders.unshift(newOrder);
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
updateOrder(id: string, updates: Partial<Order>): Order | null {
|
||||
const idx = this.orders.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const current = this.orders[idx];
|
||||
const total = updates.totalAmount !== undefined ? updates.totalAmount : current.totalAmount;
|
||||
const deposit = updates.depositAmount !== undefined ? updates.depositAmount : current.depositAmount;
|
||||
const balanceDue = updates.balanceDue !== undefined ? updates.balanceDue : (total - deposit);
|
||||
|
||||
this.orders[idx] = {
|
||||
...current,
|
||||
...updates,
|
||||
totalAmount: total,
|
||||
depositAmount: deposit,
|
||||
balanceDue,
|
||||
};
|
||||
return this.orders[idx];
|
||||
}
|
||||
|
||||
deleteOrder(id: string): boolean {
|
||||
const initialLen = this.orders.length;
|
||||
this.orders = this.orders.filter((o) => o.id !== id);
|
||||
return this.orders.length < initialLen;
|
||||
}
|
||||
|
||||
updateOrderStatus(orderId: string, status: Order['status']): Order | null {
|
||||
const order = this.orders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
|
||||
@ -6,30 +6,28 @@ const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
|
||||
|
||||
/**
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1200,
|
||||
presence: 'composing',
|
||||
},
|
||||
textMessage: {
|
||||
text: message,
|
||||
},
|
||||
delay: 1200,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -40,32 +38,31 @@ export async function sendWhatsAppMessage(toPhone: string, message: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1500,
|
||||
presence: 'composing',
|
||||
},
|
||||
mediaMessage: {
|
||||
mediatype: 'image',
|
||||
caption,
|
||||
mimetype: 'image/jpeg',
|
||||
caption: caption,
|
||||
media: imageUrl,
|
||||
},
|
||||
delay: 1500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 25000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -83,7 +80,7 @@ export function buildOrderConfirmationMessage(order: any): string {
|
||||
.map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN DRIPZONE LUXURY!* 🎉
|
||||
📌 *No. de Orden:* #${order.id}
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
|
||||
@ -22,7 +22,7 @@ async function sendApkToCarlos() {
|
||||
const apkBuffer = fs.readFileSync(apkPath);
|
||||
const base64Data = apkBuffer.toString('base64');
|
||||
|
||||
const caption = `🚀 *DripZone Sneakers - APK Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n🔐 *Novedades de la versión:*\n✅ Cambio de contraseña en perfil\n✅ Autenticación de Dos Factores (2FA) por WhatsApp OTP\n✅ Acceso con Huella / Passkey biométrico\n✅ Despacho logístico con integración a Waze\n✅ SplashScreen vertical limpio en alta definición\n\n📲 *Instalación:* Toca este archivo para actualizar tu app.`;
|
||||
const caption = `🚀 *DripZone Sneakers - APK Oficial Actualizado*\n\n📅 *Generado:* ${new Date().toLocaleString('es-CO', { timeZone: 'America/Bogota' })}\n📦 *Tamaño:* ${sizeMB} MB\n\n✨ *Novedades de esta versión:*\n✅ Gestión y módulo completo de Proveedores\n✅ Edición, actualización y eliminación de pedidos en tiempo real\n✅ Notificaciones automáticas de pedidos por WhatsApp (Evolution API v2)\n✅ Autenticación biométrica con Huella / Passkey y 2FA OTP\n✅ Splash Screen vertical en alta definición y Dock interactivo optimizado\n\n📲 *Instalación:* Toca este archivo para instalar/actualizar la app en tu teléfono Android.`;
|
||||
|
||||
console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
||||
{ href: '/admin/proveedores', label: 'Proveedores & Bodegas', icon: Building2, badge: 'Activos/Inactivos' },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,11 @@ export default function AdminProductsPage() {
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/suppliers?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && d.suppliers && setSuppliers(d.suppliers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
|
||||
593
sneaky-app/src/app/admin/proveedores/page.tsx
Normal file
593
sneaky-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Phone,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Edit2,
|
||||
Power,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
User,
|
||||
Info,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Supplier, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
|
||||
export default function AdminSuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [contactPerson, setContactPerson] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.suppliers) {
|
||||
setSuppliers(data.suppliers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching suppliers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/products?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.products) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching products:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditingSupplier(null);
|
||||
setName('');
|
||||
setPhone('57');
|
||||
setContactPerson('');
|
||||
setAddress('');
|
||||
setNotes('');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier);
|
||||
setName(supplier.name);
|
||||
setPhone(supplier.phone);
|
||||
setContactPerson(supplier.contactPerson || '');
|
||||
setAddress(supplier.address || '');
|
||||
setNotes(supplier.notes || '');
|
||||
setIsActive(supplier.isActive !== false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (supplier: Supplier) => {
|
||||
const nextStatus = !supplier.isActive;
|
||||
|
||||
// Optimistic update
|
||||
setSuppliers((prev) =>
|
||||
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: supplier.id,
|
||||
updates: { isActive: nextStatus },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(
|
||||
nextStatus
|
||||
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||
);
|
||||
setTimeout(() => setToast(null), 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling supplier active status:', e);
|
||||
fetchSuppliers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
// Edit existing
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSupplier.id,
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||
const text = encodeURIComponent(
|
||||
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Sneaky Drops.`
|
||||
);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||
|
||||
const filteredSuppliers = suppliers.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.phone.includes(search) ||
|
||||
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||
return products.filter(
|
||||
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||
).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Building2 className="w-6 h-6 text-orange-400" />
|
||||
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-400 mt-1">
|
||||
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Proveedor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="bg-orange-950/90 border border-orange-500 text-orange-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-zinc-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Total Proveedores</span>
|
||||
<Building2 className="w-4 h-4 text-orange-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||
<p className="text-[10px] text-zinc-400">Bodegas y mayoristas registrados</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Proveedores Activos</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Desactivados / Históricos</span>
|
||||
<XCircle className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||
<p className="text-[10px] text-zinc-400">Historial preservado 100% intacto</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-zinc-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Zapatillas Vinculadas</span>
|
||||
<Package className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-red-300">{products.length}</p>
|
||||
<p className="text-[10px] text-zinc-400">Modelos en catálogo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner About Historical Safety */}
|
||||
<div className="bg-gradient-to-r from-zinc-900 via-zinc-900 to-orange-950/40 border border-orange-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-zinc-300 shadow-md">
|
||||
<Info className="w-5 h-5 text-orange-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-white">
|
||||
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-zinc-900 border border-zinc-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'all' ? 'bg-white text-zinc-950 shadow' : 'text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({suppliers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||
: 'text-zinc-400 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Activos ({activeSuppliers.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||
: 'text-zinc-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-orange-400"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSuppliers.length === 0 ? (
|
||||
<div className="col-span-full bg-zinc-900 border border-zinc-800 rounded-3xl p-12 text-center text-zinc-500 space-y-2">
|
||||
<Building2 className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSuppliers.map((supplier) => {
|
||||
const isSupActive = supplier.isActive !== false;
|
||||
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className={`bg-zinc-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||
isSupActive
|
||||
? 'border-zinc-800 hover:border-orange-500/40'
|
||||
: 'border-zinc-800/80 opacity-75 bg-zinc-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* Card Top: Status & Name */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||
isSupActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleActive(supplier)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isSupActive
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||
>
|
||||
<Power className="w-3 h-3" />
|
||||
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||
|
||||
{/* Details List */}
|
||||
<div className="space-y-1.5 text-xs pt-1">
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-zinc-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-zinc-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||
</p>
|
||||
|
||||
{supplier.address && (
|
||||
<p className="text-zinc-400 flex items-center gap-1.5 text-[11px]">
|
||||
<MapPin className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
||||
<span>{supplier.address}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supplier.notes && (
|
||||
<p className="text-zinc-400 italic text-[11px] bg-zinc-950 p-2 rounded-xl border border-zinc-800 mt-1">
|
||||
"{supplier.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Bottom: Metrics & Actions */}
|
||||
<div className="pt-3 border-t border-zinc-800 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5 text-red-400" />
|
||||
<span>Modelos asignados:</span>
|
||||
</span>
|
||||
<strong className="text-white bg-zinc-950 px-2 py-0.5 rounded-lg border border-zinc-800">
|
||||
{linkedProductsCount} pares
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenWhatsApp(supplier)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(supplier)}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span>Modificar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Crear / Editar Proveedor */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-zinc-800 pb-3">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-orange-400" />
|
||||
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-zinc-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-zinc-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 573167455877"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 font-bold mb-1">Persona de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Asesor Despachos"
|
||||
value={contactPerson}
|
||||
onChange={(e) => setContactPerson(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-zinc-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-950 p-3 rounded-2xl border border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||
<p className="text-[10px] text-zinc-400">
|
||||
{isActive
|
||||
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-zinc-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateOrder(targetId, payload);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = dataService.deleteOrder(id);
|
||||
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
71
sneaky-app/src/app/api/suppliers/route.ts
Normal file
71
sneaky-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||
const suppliers = dataService.getSuppliers(includeInactive);
|
||||
return NextResponse.json({ success: true, suppliers });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !body.phone) {
|
||||
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||
}
|
||||
const newSupplier = dataService.addSupplier(body);
|
||||
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateSupplier(targetId, payload);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, supplier: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||
const success = dataService.deleteSupplier(id);
|
||||
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, Check } from 'lucide-react';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
interface MobileBottomDockProps {
|
||||
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
const [isInsideApp, setIsInsideApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isNativeApp =
|
||||
typeof window !== 'undefined' &&
|
||||
(!!(window as any).Capacitor?.isNativePlatform?.() ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'android' ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'ios' ||
|
||||
window.matchMedia?.('(display-mode: standalone)')?.matches ||
|
||||
(window.navigator as any)?.standalone === true);
|
||||
setIsInsideApp(isNativeApp);
|
||||
}, []);
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const text = encodeURIComponent(
|
||||
@ -31,6 +43,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
return (
|
||||
<div className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-zinc-950/90 backdrop-blur-xl border-t border-zinc-800/80 px-3 py-2">
|
||||
<div className="flex items-center justify-around">
|
||||
{/* 1. Inicio */}
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => setActiveTab('home')}
|
||||
@ -42,6 +55,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">Inicio</span>
|
||||
</Link>
|
||||
|
||||
{/* 2. Buscar */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('search');
|
||||
@ -58,6 +72,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">Buscar</span>
|
||||
</button>
|
||||
|
||||
{/* 3. Carrito con Badge */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('cart');
|
||||
@ -76,6 +91,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold text-white">Carrito</span>
|
||||
</button>
|
||||
|
||||
{/* 4. WhatsApp Asesoría */}
|
||||
<button
|
||||
onClick={handleWhatsApp}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
|
||||
@ -84,6 +100,8 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
|
||||
{!isInsideApp ? (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-orange-400 hover:text-orange-300"
|
||||
@ -93,6 +111,15 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
</div>
|
||||
<span className="text-[10px] font-bold">App Móvil</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-zinc-400 hover:text-white"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-[10px] font-bold">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2334,17 +2334,42 @@ class DataService {
|
||||
return this.products.length < initialLen;
|
||||
}
|
||||
|
||||
getSuppliers(): Supplier[] {
|
||||
return this.suppliers;
|
||||
getSuppliers(includeInactive: boolean = true): Supplier[] {
|
||||
if (includeInactive) return this.suppliers;
|
||||
return this.suppliers.filter((s) => s.isActive !== false);
|
||||
}
|
||||
|
||||
getSupplierById(id: string): Supplier | undefined {
|
||||
return this.suppliers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
addSupplier(supplier: Supplier): Supplier {
|
||||
this.suppliers.push(supplier);
|
||||
return supplier;
|
||||
addSupplier(supplier: Partial<Supplier>): Supplier {
|
||||
const newSupplier: Supplier = {
|
||||
id: supplier.id || `sup-${Date.now()}`,
|
||||
name: supplier.name || 'Nuevo Proveedor',
|
||||
phone: supplier.phone || '573167455877',
|
||||
contactPerson: supplier.contactPerson || '',
|
||||
address: supplier.address || '',
|
||||
notes: supplier.notes || '',
|
||||
isActive: supplier.isActive !== undefined ? supplier.isActive : true,
|
||||
};
|
||||
this.suppliers.push(newSupplier);
|
||||
return newSupplier;
|
||||
}
|
||||
|
||||
updateSupplier(id: string, updates: Partial<Supplier>): Supplier | null {
|
||||
const idx = this.suppliers.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.suppliers[idx] = { ...this.suppliers[idx], ...updates };
|
||||
return this.suppliers[idx];
|
||||
}
|
||||
|
||||
deleteSupplier(id: string): boolean {
|
||||
// Soft delete (desactivación segura para preservar 100% el historial de pedidos y productos)
|
||||
const supplier = this.suppliers.find((s) => s.id === id);
|
||||
if (!supplier) return false;
|
||||
supplier.isActive = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getCities(): City[] {
|
||||
@ -2382,6 +2407,10 @@ class DataService {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getOrderById(id: string): Order | undefined {
|
||||
return this.orders.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
addOrder(order: Order): Order {
|
||||
this.orders.unshift(order);
|
||||
return order;
|
||||
@ -2389,7 +2418,7 @@ class DataService {
|
||||
|
||||
createOrder(orderData: Partial<Order>): Order {
|
||||
const newOrder: Order = {
|
||||
id: `ord-${Date.now()}`,
|
||||
id: orderData.id || `ord-${Date.now()}`,
|
||||
customerName: orderData.customerName || 'Cliente',
|
||||
customerPhone: orderData.customerPhone || '',
|
||||
address: orderData.address || '',
|
||||
@ -2399,18 +2428,43 @@ class DataService {
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shippingCost || 0,
|
||||
totalAmount: orderData.totalAmount || 0,
|
||||
depositAmount: orderData.depositAmount || 10000,
|
||||
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000),
|
||||
depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
|
||||
balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
|
||||
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
|
||||
paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
|
||||
status: 'pending',
|
||||
status: orderData.status || 'pending',
|
||||
notes: orderData.notes,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: orderData.createdAt || new Date().toISOString(),
|
||||
};
|
||||
this.orders.unshift(newOrder);
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
updateOrder(id: string, updates: Partial<Order>): Order | null {
|
||||
const idx = this.orders.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const current = this.orders[idx];
|
||||
const total = updates.totalAmount !== undefined ? updates.totalAmount : current.totalAmount;
|
||||
const deposit = updates.depositAmount !== undefined ? updates.depositAmount : current.depositAmount;
|
||||
const balanceDue = updates.balanceDue !== undefined ? updates.balanceDue : (total - deposit);
|
||||
|
||||
this.orders[idx] = {
|
||||
...current,
|
||||
...updates,
|
||||
totalAmount: total,
|
||||
depositAmount: deposit,
|
||||
balanceDue,
|
||||
};
|
||||
return this.orders[idx];
|
||||
}
|
||||
|
||||
deleteOrder(id: string): boolean {
|
||||
const initialLen = this.orders.length;
|
||||
this.orders = this.orders.filter((o) => o.id !== id);
|
||||
return this.orders.length < initialLen;
|
||||
}
|
||||
|
||||
updateOrderStatus(orderId: string, status: Order['status']): Order | null {
|
||||
const order = this.orders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
|
||||
@ -6,30 +6,28 @@ const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
|
||||
|
||||
/**
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1200,
|
||||
presence: 'composing',
|
||||
},
|
||||
textMessage: {
|
||||
text: message,
|
||||
},
|
||||
delay: 1200,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -40,32 +38,31 @@ export async function sendWhatsAppMessage(toPhone: string, message: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1500,
|
||||
presence: 'composing',
|
||||
},
|
||||
mediaMessage: {
|
||||
mediatype: 'image',
|
||||
caption,
|
||||
mimetype: 'image/jpeg',
|
||||
caption: caption,
|
||||
media: imageUrl,
|
||||
},
|
||||
delay: 1500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 25000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -83,7 +80,7 @@ export function buildOrderConfirmationMessage(order: any): string {
|
||||
.map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
|
||||
.join('\n\n');
|
||||
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉
|
||||
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKY DROPS!* 🎉
|
||||
📌 *No. de Orden:* #${order.id}
|
||||
|
||||
👤 *Cliente:* ${order.customerName}
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
MessageSquare,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard },
|
||||
{ href: '/admin/proveedores', label: 'Proveedores & Bodegas', icon: Building2, badge: 'Activos/Inactivos' },
|
||||
{ href: '/admin/grupos-whatsapp', label: 'Grupos WhatsApp', icon: MessageSquare, badge: 'Monitoreo 24/7' },
|
||||
{ href: '/admin/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
|
||||
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,6 +33,11 @@ export default function AdminProductsPage() {
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && setProducts(d.products))
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/suppliers?all=true')
|
||||
.then((r) => r.json())
|
||||
.then((d) => d.success && d.suppliers && setSuppliers(d.suppliers))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleToggleProductStatus = async (product: Product) => {
|
||||
|
||||
593
zapply-app/src/app/admin/proveedores/page.tsx
Normal file
593
zapply-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Phone,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Edit2,
|
||||
Power,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
User,
|
||||
Info,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Supplier, Product } from '@/types';
|
||||
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||
|
||||
export default function AdminSuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [contactPerson, setContactPerson] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers();
|
||||
fetchProducts();
|
||||
}, []);
|
||||
|
||||
const fetchSuppliers = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.suppliers) {
|
||||
setSuppliers(data.suppliers);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching suppliers:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/products?all=true');
|
||||
const data = await res.json();
|
||||
if (data.success && data.products) {
|
||||
setProducts(data.products);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching products:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setEditingSupplier(null);
|
||||
setName('');
|
||||
setPhone('57');
|
||||
setContactPerson('');
|
||||
setAddress('');
|
||||
setNotes('');
|
||||
setIsActive(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (supplier: Supplier) => {
|
||||
setEditingSupplier(supplier);
|
||||
setName(supplier.name);
|
||||
setPhone(supplier.phone);
|
||||
setContactPerson(supplier.contactPerson || '');
|
||||
setAddress(supplier.address || '');
|
||||
setNotes(supplier.notes || '');
|
||||
setIsActive(supplier.isActive !== false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (supplier: Supplier) => {
|
||||
const nextStatus = !supplier.isActive;
|
||||
|
||||
// Optimistic update
|
||||
setSuppliers((prev) =>
|
||||
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: supplier.id,
|
||||
updates: { isActive: nextStatus },
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(
|
||||
nextStatus
|
||||
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||
);
|
||||
setTimeout(() => setToast(null), 4500);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling supplier active status:', e);
|
||||
fetchSuppliers();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||
|
||||
try {
|
||||
if (editingSupplier) {
|
||||
// Edit existing
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: editingSupplier.id,
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
} else {
|
||||
// Create new
|
||||
const res = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
phone: finalPhone,
|
||||
contactPerson,
|
||||
address,
|
||||
notes,
|
||||
isActive,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
setIsModalOpen(false);
|
||||
fetchSuppliers();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||
const text = encodeURIComponent(
|
||||
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Zapply Cali.`
|
||||
);
|
||||
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||
};
|
||||
|
||||
// Metrics
|
||||
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||
|
||||
const filteredSuppliers = suppliers.filter((s) => {
|
||||
const matchesSearch =
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.phone.includes(search) ||
|
||||
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||
return products.filter(
|
||||
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||
).length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||
<Building2 className="w-6 h-6 text-emerald-400" />
|
||||
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Proveedor</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
<div className="bg-emerald-950/90 border border-emerald-500 text-emerald-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||
<span>{toast}</span>
|
||||
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Total Proveedores</span>
|
||||
<Building2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Bodegas y mayoristas registrados</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Proveedores Activos</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Desactivados / Históricos</span>
|
||||
<XCircle className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Historial preservado 100% intacto</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||
<span>Zapatillas Vinculadas</span>
|
||||
<Package className="w-4 h-4 text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-black text-cyan-300">{products.length}</p>
|
||||
<p className="text-[10px] text-slate-400">Modelos en catálogo</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner About Historical Safety */}
|
||||
<div className="bg-gradient-to-r from-slate-900 via-slate-900 to-emerald-950/40 border border-emerald-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-slate-300 shadow-md">
|
||||
<Info className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold text-white">
|
||||
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Todos ({suppliers.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||
: 'text-slate-400 hover:text-emerald-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||
<span>Activos ({activeSuppliers.length})</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||
: 'text-slate-400 hover:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs w-full">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-emerald-400"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredSuppliers.length === 0 ? (
|
||||
<div className="col-span-full bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center text-slate-500 space-y-2">
|
||||
<Building2 className="w-12 h-12 text-slate-600 mx-auto" />
|
||||
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSuppliers.map((supplier) => {
|
||||
const isSupActive = supplier.isActive !== false;
|
||||
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className={`bg-slate-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||
isSupActive
|
||||
? 'border-slate-800 hover:border-emerald-500/40'
|
||||
: 'border-slate-800/80 opacity-75 bg-slate-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* Card Top: Status & Name */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||
isSupActive
|
||||
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||
}`}
|
||||
/>
|
||||
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => handleToggleActive(supplier)}
|
||||
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||
isSupActive
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
}`}
|
||||
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||
>
|
||||
<Power className="w-3 h-3" />
|
||||
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||
|
||||
{/* Details List */}
|
||||
<div className="space-y-1.5 text-xs pt-1">
|
||||
{supplier.contactPerson && (
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-slate-300 flex items-center gap-1.5">
|
||||
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||
</p>
|
||||
|
||||
{supplier.address && (
|
||||
<p className="text-slate-400 flex items-center gap-1.5 text-[11px]">
|
||||
<MapPin className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
<span>{supplier.address}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{supplier.notes && (
|
||||
<p className="text-slate-400 italic text-[11px] bg-slate-950 p-2 rounded-xl border border-slate-800 mt-1">
|
||||
"{supplier.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Bottom: Metrics & Actions */}
|
||||
<div className="pt-3 border-t border-slate-800 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Package className="w-3.5 h-3.5 text-cyan-400" />
|
||||
<span>Modelos asignados:</span>
|
||||
</span>
|
||||
<strong className="text-white bg-slate-950 px-2 py-0.5 rounded-lg border border-slate-800">
|
||||
{linkedProductsCount} pares
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenWhatsApp(supplier)}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||
>
|
||||
<MessageCircle className="w-3.5 h-3.5" />
|
||||
<span>WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenEditModal(supplier)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Modificar</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Crear / Editar Proveedor */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-emerald-400" />
|
||||
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-slate-400 hover:text-white cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
placeholder="ej: 573167455877"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Persona de Contacto</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. Asesor Despachos"
|
||||
value={contactPerson}
|
||||
onChange={(e) => setContactPerson(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{isActive
|
||||
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateOrder(targetId, payload);
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, order: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = dataService.deleteOrder(id);
|
||||
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
71
zapply-app/src/app/api/suppliers/route.ts
Normal file
71
zapply-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { dataService } from '@/lib/data-service';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||
const suppliers = dataService.getSuppliers(includeInactive);
|
||||
return NextResponse.json({ success: true, suppliers });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body.name || !body.phone) {
|
||||
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||
}
|
||||
const newSupplier = dataService.addSupplier(body);
|
||||
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, updates, ...rest } = body;
|
||||
const targetId = id || rest.id;
|
||||
if (!targetId) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = updates || rest;
|
||||
const updated = dataService.updateSupplier(targetId, payload);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, supplier: updated });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
let id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
id = body?.id;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||
const success = dataService.deleteSupplier(id);
|
||||
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, Check } from 'lucide-react';
|
||||
import { Home, Search, ShoppingBag, MessageCircle, Download, User } from 'lucide-react';
|
||||
import { useBrand } from '@/context/BrandContext';
|
||||
|
||||
interface MobileBottomDockProps {
|
||||
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
}) => {
|
||||
const { currentBrand } = useBrand();
|
||||
const [activeTab, setActiveTab] = useState('home');
|
||||
const [isInsideApp, setIsInsideApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isNativeApp =
|
||||
typeof window !== 'undefined' &&
|
||||
(!!(window as any).Capacitor?.isNativePlatform?.() ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'android' ||
|
||||
(window as any).Capacitor?.getPlatform?.() === 'ios' ||
|
||||
window.matchMedia?.('(display-mode: standalone)')?.matches ||
|
||||
(window.navigator as any)?.standalone === true);
|
||||
setIsInsideApp(isNativeApp);
|
||||
}, []);
|
||||
|
||||
const handleWhatsApp = () => {
|
||||
const text = encodeURIComponent(
|
||||
@ -84,6 +96,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
<span className="text-[10px] font-bold">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
{!isInsideApp ? (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300"
|
||||
@ -93,6 +106,15 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
|
||||
</div>
|
||||
<span className="text-[10px] font-bold">App Móvil</span>
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex flex-col items-center gap-0.5 cursor-pointer text-slate-400 hover:text-white"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
<span className="text-[10px] font-bold">Admin</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -5,6 +5,8 @@ import { User } from '@/types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
sendOtp: (identifier: string) => Promise<{ success: boolean; error?: string; message?: string; phone?: string }>;
|
||||
verifyOtp: (identifier: string, code: string) => Promise<{ success: boolean; error?: string }>;
|
||||
login: (identifier: string) => Promise<{ success: boolean; error?: string }>;
|
||||
register: (data: { name: string; email: string; phone: string; city?: string; address?: string }) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
@ -28,26 +30,47 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = async (identifier: string) => {
|
||||
const sendOtp = async (identifier: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
const res = await fetch('/api/auth/send-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
return { success: true, message: data.message, phone: data.phone };
|
||||
}
|
||||
return { success: false, error: data.error || 'Error al enviar código' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const verifyOtp = async (identifier: string, code: string) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/verify-otp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier, code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.user) {
|
||||
setUser(data.user);
|
||||
localStorage.setItem('zapply_user', JSON.stringify(data.user));
|
||||
setIsAuthModalOpen(false);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.error || 'Error al iniciar sesión' };
|
||||
return { success: false, error: data.error || 'Código incorrecto' };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (identifier: string) => {
|
||||
return sendOtp(identifier);
|
||||
};
|
||||
|
||||
const register = async (data: { name: string; email: string; phone: string; city?: string; address?: string }) => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
@ -73,19 +96,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
localStorage.removeItem('zapply_user');
|
||||
};
|
||||
|
||||
const openAuthModal = () => setIsAuthModalOpen(true);
|
||||
const closeAuthModal = () => setIsAuthModalOpen(false);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
sendOtp,
|
||||
verifyOtp,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
isAuthModalOpen,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
openAuthModal: () => setIsAuthModalOpen(true),
|
||||
closeAuthModal: () => setIsAuthModalOpen(false),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -2334,17 +2334,42 @@ class DataService {
|
||||
return this.products.length < initialLen;
|
||||
}
|
||||
|
||||
getSuppliers(): Supplier[] {
|
||||
return this.suppliers;
|
||||
getSuppliers(includeInactive: boolean = true): Supplier[] {
|
||||
if (includeInactive) return this.suppliers;
|
||||
return this.suppliers.filter((s) => s.isActive !== false);
|
||||
}
|
||||
|
||||
getSupplierById(id: string): Supplier | undefined {
|
||||
return this.suppliers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
addSupplier(supplier: Supplier): Supplier {
|
||||
this.suppliers.push(supplier);
|
||||
return supplier;
|
||||
addSupplier(supplier: Partial<Supplier>): Supplier {
|
||||
const newSupplier: Supplier = {
|
||||
id: supplier.id || `sup-${Date.now()}`,
|
||||
name: supplier.name || 'Nuevo Proveedor',
|
||||
phone: supplier.phone || '573167455877',
|
||||
contactPerson: supplier.contactPerson || '',
|
||||
address: supplier.address || '',
|
||||
notes: supplier.notes || '',
|
||||
isActive: supplier.isActive !== undefined ? supplier.isActive : true,
|
||||
};
|
||||
this.suppliers.push(newSupplier);
|
||||
return newSupplier;
|
||||
}
|
||||
|
||||
updateSupplier(id: string, updates: Partial<Supplier>): Supplier | null {
|
||||
const idx = this.suppliers.findIndex((s) => s.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.suppliers[idx] = { ...this.suppliers[idx], ...updates };
|
||||
return this.suppliers[idx];
|
||||
}
|
||||
|
||||
deleteSupplier(id: string): boolean {
|
||||
// Soft delete (desactivación segura para preservar 100% el historial de pedidos y productos)
|
||||
const supplier = this.suppliers.find((s) => s.id === id);
|
||||
if (!supplier) return false;
|
||||
supplier.isActive = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getCities(): City[] {
|
||||
@ -2382,6 +2407,10 @@ class DataService {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getOrderById(id: string): Order | undefined {
|
||||
return this.orders.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
addOrder(order: Order): Order {
|
||||
this.orders.unshift(order);
|
||||
return order;
|
||||
@ -2389,7 +2418,7 @@ class DataService {
|
||||
|
||||
createOrder(orderData: Partial<Order>): Order {
|
||||
const newOrder: Order = {
|
||||
id: `ord-${Date.now()}`,
|
||||
id: orderData.id || `ord-${Date.now()}`,
|
||||
customerName: orderData.customerName || 'Cliente',
|
||||
customerPhone: orderData.customerPhone || '',
|
||||
address: orderData.address || '',
|
||||
@ -2399,18 +2428,43 @@ class DataService {
|
||||
subtotal: orderData.subtotal || 0,
|
||||
shippingCost: orderData.shippingCost || 0,
|
||||
totalAmount: orderData.totalAmount || 0,
|
||||
depositAmount: orderData.depositAmount || 10000,
|
||||
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000),
|
||||
depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
|
||||
balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
|
||||
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
|
||||
paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
|
||||
status: 'pending',
|
||||
status: orderData.status || 'pending',
|
||||
notes: orderData.notes,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdAt: orderData.createdAt || new Date().toISOString(),
|
||||
};
|
||||
this.orders.unshift(newOrder);
|
||||
return newOrder;
|
||||
}
|
||||
|
||||
updateOrder(id: string, updates: Partial<Order>): Order | null {
|
||||
const idx = this.orders.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const current = this.orders[idx];
|
||||
const total = updates.totalAmount !== undefined ? updates.totalAmount : current.totalAmount;
|
||||
const deposit = updates.depositAmount !== undefined ? updates.depositAmount : current.depositAmount;
|
||||
const balanceDue = updates.balanceDue !== undefined ? updates.balanceDue : (total - deposit);
|
||||
|
||||
this.orders[idx] = {
|
||||
...current,
|
||||
...updates,
|
||||
totalAmount: total,
|
||||
depositAmount: deposit,
|
||||
balanceDue,
|
||||
};
|
||||
return this.orders[idx];
|
||||
}
|
||||
|
||||
deleteOrder(id: string): boolean {
|
||||
const initialLen = this.orders.length;
|
||||
this.orders = this.orders.filter((o) => o.id !== id);
|
||||
return this.orders.length < initialLen;
|
||||
}
|
||||
|
||||
updateOrderStatus(orderId: string, status: Order['status']): Order | null {
|
||||
const order = this.orders.find((o) => o.id === orderId);
|
||||
if (!order) return null;
|
||||
|
||||
@ -6,30 +6,29 @@ const EVOLUTION_API_KEY = process.env.EVOLUTION_API_KEY || 'EE49029EEB85-4568-8F
|
||||
const EVOLUTION_INSTANCE_NAME = process.env.EVOLUTION_INSTANCE_NAME || 'AutomataAlmeira';
|
||||
|
||||
/**
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API
|
||||
* Enviar mensaje de texto por WhatsApp a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
// Evolution API v2 acepta el número directamente con código de país (ej. 573167455877)
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1200,
|
||||
presence: 'composing',
|
||||
},
|
||||
textMessage: {
|
||||
text: message,
|
||||
},
|
||||
delay: 1200,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 20000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
@ -40,32 +39,31 @@ export async function sendWhatsAppMessage(toPhone: string, message: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API
|
||||
* Enviar mensaje con foto/catálogo a través de Evolution API v2
|
||||
*/
|
||||
export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
|
||||
const cleanPhone = toPhone.replace(/\D/g, '');
|
||||
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`;
|
||||
const cleanDigits = toPhone.replace(/\D/g, '');
|
||||
if (!cleanDigits) return false;
|
||||
|
||||
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
|
||||
{
|
||||
number: recipient,
|
||||
options: {
|
||||
delay: 1500,
|
||||
presence: 'composing',
|
||||
},
|
||||
mediaMessage: {
|
||||
mediatype: 'image',
|
||||
caption,
|
||||
mimetype: 'image/jpeg',
|
||||
caption: caption,
|
||||
media: imageUrl,
|
||||
},
|
||||
delay: 1500,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
apikey: EVOLUTION_API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 25000,
|
||||
}
|
||||
);
|
||||
return response.status === 200 || response.status === 201;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user