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:
AI Bot 2026-08-27 15:05:13 -05:00
parent 4e1476e1a9
commit e675ec3641
40 changed files with 5911 additions and 538 deletions

View File

@ -15,6 +15,7 @@ import {
Store, Store,
Users, Users,
MessageSquare, MessageSquare,
Building2,
} from 'lucide-react'; } from 'lucide-react';
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, { 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/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/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store }, { href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,11 @@ export default function AdminProductsPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => d.success && setProducts(d.products)) .then((d) => d.success && setProducts(d.products))
.catch(() => {}); .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) => { const handleToggleProductStatus = async (product: Product) => {

View 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>
);
}

View File

@ -28,3 +28,49 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 }); 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 });
}
}

View 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 });
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; 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'; import { useBrand } from '@/context/BrandContext';
interface MobileBottomDockProps { interface MobileBottomDockProps {
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
}) => { }) => {
const { currentBrand } = useBrand(); const { currentBrand } = useBrand();
const [activeTab, setActiveTab] = useState('home'); 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 handleWhatsApp = () => {
const text = encodeURIComponent( const text = encodeURIComponent(
@ -88,17 +100,28 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">WhatsApp</span> <span className="text-[10px] font-bold">WhatsApp</span>
</button> </button>
{/* 5. Instalar App */} {/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
{!isInsideApp ? (
<button <button
onClick={onOpenInstallModal} 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"> <div className="relative">
<Download className="w-5 h-5 animate-bounce" /> <Download className="w-5 h-5 animate-bounce" />
</div> </div>
<span className="text-[10px] font-bold">App Móvil</span> <span className="text-[10px] font-bold">App Móvil</span>
</button> </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>
</div> </div>
); );
}; };

View File

@ -5,6 +5,8 @@ import { User } from '@/types';
interface AuthContextType { interface AuthContextType {
user: User | null; 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 }>; 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 }>; register: (data: { name: string; email: string; phone: string; city?: string; address?: string }) => Promise<{ success: boolean; error?: string }>;
logout: () => void; 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 { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/send-otp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }), body: JSON.stringify({ identifier }),
}); });
const data = await res.json(); 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) { if (data.success && data.user) {
setUser(data.user); setUser(data.user);
localStorage.setItem('almeira_user', JSON.stringify(data.user)); localStorage.setItem('almeira_user', JSON.stringify(data.user));
setIsAuthModalOpen(false); setIsAuthModalOpen(false);
return { success: true }; 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) { } catch (e: any) {
return { success: false, error: e.message }; 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 }) => { const register = async (data: { name: string; email: string; phone: string; city?: string; address?: string }) => {
try { try {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
@ -73,19 +96,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
localStorage.removeItem('almeira_user'); localStorage.removeItem('almeira_user');
}; };
const openAuthModal = () => setIsAuthModalOpen(true);
const closeAuthModal = () => setIsAuthModalOpen(false);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
sendOtp,
verifyOtp,
login, login,
register, register,
logout, logout,
isAuthModalOpen, isAuthModalOpen,
openAuthModal, openAuthModal: () => setIsAuthModalOpen(true),
closeAuthModal, closeAuthModal: () => setIsAuthModalOpen(false),
}} }}
> >
{children} {children}

View File

@ -2334,17 +2334,42 @@ class DataService {
return this.products.length < initialLen; return this.products.length < initialLen;
} }
getSuppliers(): Supplier[] { getSuppliers(includeInactive: boolean = true): Supplier[] {
return this.suppliers; if (includeInactive) return this.suppliers;
return this.suppliers.filter((s) => s.isActive !== false);
} }
getSupplierById(id: string): Supplier | undefined { getSupplierById(id: string): Supplier | undefined {
return this.suppliers.find((s) => s.id === id); return this.suppliers.find((s) => s.id === id);
} }
addSupplier(supplier: Supplier): Supplier { addSupplier(supplier: Partial<Supplier>): Supplier {
this.suppliers.push(supplier); const newSupplier: Supplier = {
return 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[] { getCities(): City[] {
@ -2382,6 +2407,10 @@ class DataService {
return this.orders; return this.orders;
} }
getOrderById(id: string): Order | undefined {
return this.orders.find((o) => o.id === id);
}
addOrder(order: Order): Order { addOrder(order: Order): Order {
this.orders.unshift(order); this.orders.unshift(order);
return order; return order;
@ -2389,7 +2418,7 @@ class DataService {
createOrder(orderData: Partial<Order>): Order { createOrder(orderData: Partial<Order>): Order {
const newOrder: Order = { const newOrder: Order = {
id: `ord-${Date.now()}`, id: orderData.id || `ord-${Date.now()}`,
customerName: orderData.customerName || 'Cliente', customerName: orderData.customerName || 'Cliente',
customerPhone: orderData.customerPhone || '', customerPhone: orderData.customerPhone || '',
address: orderData.address || '', address: orderData.address || '',
@ -2399,18 +2428,43 @@ class DataService {
subtotal: orderData.subtotal || 0, subtotal: orderData.subtotal || 0,
shippingCost: orderData.shippingCost || 0, shippingCost: orderData.shippingCost || 0,
totalAmount: orderData.totalAmount || 0, totalAmount: orderData.totalAmount || 0,
depositAmount: orderData.depositAmount || 10000, depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000), balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega', paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
paymentMethodName: orderData.paymentMethodName || 'Contraentrega', paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
status: 'pending', status: orderData.status || 'pending',
notes: orderData.notes, notes: orderData.notes,
createdAt: new Date().toISOString(), createdAt: orderData.createdAt || new Date().toISOString(),
}; };
this.orders.unshift(newOrder); this.orders.unshift(newOrder);
return 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 { updateOrderStatus(orderId: string, status: Order['status']): Order | null {
const order = this.orders.find((o) => o.id === orderId); const order = this.orders.find((o) => o.id === orderId);
if (!order) return null; if (!order) return null;

View File

@ -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'; 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> { export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1200,
presence: 'composing',
},
textMessage: {
text: message, text: message,
}, delay: 1200,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 20000,
} }
); );
return response.status === 200 || response.status === 201; 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> { export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1500,
presence: 'composing',
},
mediaMessage: {
mediatype: 'image', mediatype: 'image',
caption, mimetype: 'image/jpeg',
caption: caption,
media: imageUrl, media: imageUrl,
}, delay: 1500,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 25000,
} }
); );
return response.status === 200 || response.status === 201; 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)}`) .map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
.join('\n\n'); .join('\n\n');
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉 return `🎉 *¡GRACIAS POR TU PEDIDO EN ALMEIRA STORE!* 🎉
📌 *No. de Orden:* #${order.id} 📌 *No. de Orden:* #${order.id}
👤 *Cliente:* ${order.customerName} 👤 *Cliente:* ${order.customerName}

View File

@ -16,6 +16,7 @@ import {
Store, Store,
Users, Users,
MessageSquare, MessageSquare,
Building2,
} from 'lucide-react'; } from 'lucide-react';
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@ -23,6 +24,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, { 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/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/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store }, { href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },

View File

@ -20,25 +20,90 @@ import {
Sparkles, Sparkles,
Compass, Compass,
Check, Check,
AlertCircle AlertCircle,
Edit3,
Trash2,
Plus,
X,
CreditCard,
User,
FileText
} from 'lucide-react'; } from 'lucide-react';
import { Order, OrderItem } from '@/types'; import { Order, OrderItem, Supplier, City } from '@/types';
import { formatCOP } from '@/lib/pricing-engine'; import { formatCOP } from '@/lib/pricing-engine';
export default function AdminOrdersPage() { export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]); const [orders, setOrders] = useState<Order[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [activeTab, setActiveTab] = useState<'individual' | 'suppliers' | 'routes'>('individual'); const [activeTab, setActiveTab] = useState<'individual' | 'suppliers' | 'routes'>('individual');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [toastMessage, setToastMessage] = useState<string | null>(null); 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(() => { useEffect(() => {
fetch('/api/orders') fetchOrders();
.then((r) => r.json()) fetchSuppliers();
.then((d) => d.success && setOrders(d.orders)) fetchCities();
.catch(() => {});
}, []); }, []);
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']) => { const handleStatusChange = async (orderId: string, newStatus: Order['status']) => {
setOrders((prev) => setOrders((prev) =>
prev.map((o) => (o.id === orderId ? { ...o, status: newStatus } : o)) 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 }), body: JSON.stringify({ id: orderId, status: newStatus }),
}); });
setToastMessage(`Estado de la orden #${orderId} actualizado a "${newStatus}".`); setToastMessage(`Estado de la orden #${orderId} actualizado a "${newStatus}".`);
setTimeout(() => setToastMessage(null), 4000);
} catch (e) { } catch (e) {
console.error(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 // 1. Notificar separación individual a proveedor
const handleNotifySingleSupplier = (order: Order, item: OrderItem) => { const handleNotifySingleSupplier = (order: Order, item: OrderItem) => {
const supplierPhone = item.supplierPhone || '573134848203'; const supplierPhone = item.supplierPhone || '573167455877';
const supplierName = item.supplierName || 'Proveedor Mayorista'; const supplierName = item.supplierName || 'Proveedor Mayorista';
const supplierCost = item.supplierPrice ? formatCOP(item.supplierPrice) : 'Precio 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}.`); setToastMessage(`📲 Manifiesto de ${totalPairs} pares enviado a ${supplierName}.`);
}; };
// 3. Abrir en Waze
const handleOpenWaze = (address: string, city: string) => { const handleOpenWaze = (address: string, city: string) => {
const query = encodeURIComponent(`${address}, ${city}, Colombia`); const query = encodeURIComponent(`${address}, ${city}, Colombia`);
// Waze Universal Deep Link
const wazeUrl = `https://waze.com/ul?q=${query}&navigate=yes`; const wazeUrl = `https://waze.com/ul?q=${query}&navigate=yes`;
window.open(wazeUrl, '_blank'); window.open(wazeUrl, '_blank');
}; };
// 4. Abrir en Google Maps
const handleOpenGoogleMaps = (address: string, city: string) => { const handleOpenGoogleMaps = (address: string, city: string) => {
const query = encodeURIComponent(`${address}, ${city}, Colombia`); const query = encodeURIComponent(`${address}, ${city}, Colombia`);
const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${query}`; const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${query}`;
window.open(mapsUrl, '_blank'); window.open(mapsUrl, '_blank');
}; };
// 5. Avisar al cliente que el mensajero va en camino
const handleNotifyCustomerOnTheWay = (order: Order) => { const handleNotifyCustomerOnTheWay = (order: Order) => {
const cleanPhone = order.customerPhone.startsWith('57') ? order.customerPhone : `57${order.customerPhone}`; 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'); window.open(`https://wa.me/${cleanPhone}?text=${encodeURIComponent(text)}`, '_blank');
handleStatusChange(order.id, 'dispatched'); handleStatusChange(order.id, 'dispatched');
@ -138,7 +343,6 @@ export default function AdminOrdersPage() {
} }
}; };
// Filtrado de pedidos
const filteredOrders = orders.filter((o) => { const filteredOrders = orders.filter((o) => {
const matchesSearch = const matchesSearch =
o.customerName.toLowerCase().includes(search.toLowerCase()) || o.customerName.toLowerCase().includes(search.toLowerCase()) ||
@ -152,15 +356,19 @@ export default function AdminOrdersPage() {
return true; return true;
}); });
// Agrupación por Proveedor // Consolidado por Proveedor
const supplierGroups = orders.reduce((acc, order) => { const supplierGroups = orders.reduce((acc, order) => {
order.items.forEach((item) => { order.items.forEach((item) => {
const suppName = item.supplierName || 'DIFUSION JOVIC STORE'; const suppName = item.supplierName || '💎 DIFUSIÓN JOVIC STORE COLOMBIA';
const suppPhone = item.supplierPhone || '573134848203'; 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]) { if (!acc[suppName]) {
acc[suppName] = { acc[suppName] = {
name: suppName, name: suppName,
phone: suppPhone, phone: suppPhone,
isActive: isActiveSup,
entries: [], entries: [],
totalPairs: 0, totalPairs: 0,
totalCost: 0, totalCost: 0,
@ -171,9 +379,8 @@ export default function AdminOrdersPage() {
acc[suppName].totalCost += (item.supplierPrice || 0) * item.quantity; acc[suppName].totalCost += (item.supplierPrice || 0) * item.quantity;
}); });
return acc; 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 pendingDeliveries = orders.filter((o) => o.status !== 'delivered' && o.status !== 'cancelled');
const totalBalanceToCollect = pendingDeliveries.reduce((acc, curr) => acc + curr.balanceDue, 0); 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> <span>Centro Logístico: Pedidos, Proveedores & Rutas Waze</span>
</h1> </h1>
<p className="text-xs text-neutral-400 mt-1"> <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> </p>
</div> </div>
</div> </div>
{toastMessage && ( {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> <span>{toastMessage}</span>
<button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3"> <button onClick={() => setToastMessage(null)} className="font-bold text-white cursor-pointer ml-3">
✕ ✕
@ -207,7 +414,7 @@ export default function AdminOrdersPage() {
onClick={() => setActiveTab('individual')} 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 ${ className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === 'individual' 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' : 'text-neutral-400 hover:text-white'
}`} }`}
> >
@ -219,11 +426,11 @@ export default function AdminOrdersPage() {
onClick={() => setActiveTab('suppliers')} 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 ${ className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === 'suppliers' 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' : '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> <span>2. Consolidado por Proveedor ({Object.keys(supplierGroups).length})</span>
</button> </button>
@ -231,11 +438,11 @@ export default function AdminOrdersPage() {
onClick={() => setActiveTab('routes')} 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 ${ className={`py-3 px-4 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 cursor-pointer ${
activeTab === 'routes' 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' : '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> <span>3. Rutas de Entrega & Waze ({pendingDeliveries.length})</span>
</button> </button>
</div> </div>
@ -243,7 +450,6 @@ export default function AdminOrdersPage() {
{/* TAB 1: INDIVIDUAL ORDERS LIST */} {/* TAB 1: INDIVIDUAL ORDERS LIST */}
{activeTab === 'individual' && ( {activeTab === 'individual' && (
<div className="space-y-4"> <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 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"> <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) => ( {['all', 'pending', 'deposit_received', 'supplier_notified', 'dispatched', 'delivered'].map((st) => (
@ -270,7 +476,6 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Orders Cards Grid */}
<div className="space-y-4"> <div className="space-y-4">
{filteredOrders.length === 0 ? ( {filteredOrders.length === 0 ? (
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500"> <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} 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" 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 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"> <div className="flex items-center space-x-3">
<span className="font-black text-white text-base">Orden #{order.id}</span> <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> <span className="text-[11px] text-neutral-500 font-mono">{order.createdAt}</span>
</div> </div>
{/* Status Dropdown */}
<div className="flex items-center space-x-2"> <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 <select
value={order.status} value={order.status}
onChange={(e) => handleStatusChange(order.id, e.target.value as any)} onChange={(e) => handleStatusChange(order.id, e.target.value as any)}
@ -308,9 +528,7 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Customer Info & Financials */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs"> <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"> <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="font-bold text-neutral-400 uppercase text-[10px]">Cliente & Destino</p>
<p className="text-white font-bold text-sm">{order.customerName}</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" /> <MapPin className="w-3 h-3 shrink-0 text-purple-400 mt-0.5" />
<span>{order.address} ({order.cityName})</span> <span>{order.address} ({order.cityName})</span>
</p> </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> </div>
{/* Financial Balance */}
<div className="space-y-1 bg-neutral-950/60 p-3 rounded-2xl border border-neutral-800/80"> <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> <p className="font-bold text-neutral-400 uppercase text-[10px]">Resumen de Cobro</p>
<div className="flex justify-between text-neutral-300"> <div className="flex justify-between text-neutral-300">
@ -332,7 +554,7 @@ export default function AdminOrdersPage() {
</div> </div>
<div className="flex justify-between text-neutral-300"> <div className="flex justify-between text-neutral-300">
<span>Anticipo / Separación:</span> <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>
<div className="flex justify-between text-neutral-300 pt-1 border-t border-neutral-800 font-bold"> <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> <span className="text-purple-300">Saldo Contraentrega:</span>
@ -340,11 +562,10 @@ export default function AdminOrdersPage() {
</div> </div>
</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"> <div className="space-y-2 bg-neutral-950/60 p-3 rounded-2xl border border-neutral-800/80 flex flex-col justify-center">
<button <button
onClick={() => handleOpenWaze(order.address, order.cityName)} 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" /> <Compass className="w-3.5 h-3.5" />
<span>Navegar con Waze</span> <span>Navegar con Waze</span>
@ -360,7 +581,6 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Items breakdown with Supplier notification button */}
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<p className="font-bold text-neutral-400 uppercase text-[10px]">Pares en esta orden:</p> <p className="font-bold text-neutral-400 uppercase text-[10px]">Pares en esta orden:</p>
<div className="space-y-2"> <div className="space-y-2">
@ -374,14 +594,14 @@ export default function AdminOrdersPage() {
<div> <div>
<p className="font-bold text-white text-xs">{item.productName}</p> <p className="font-bold text-white text-xs">{item.productName}</p>
<p className="text-[11px] text-neutral-400"> <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> </p>
</div> </div>
</div> </div>
<button <button
onClick={() => handleNotifySingleSupplier(order, item)} 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" /> <Send className="w-3.5 h-3.5" />
<span>Separar con Proveedor</span> <span>Separar con Proveedor</span>
@ -406,7 +626,7 @@ export default function AdminOrdersPage() {
<span>Consolidado Automático de Compras & Separaciones</span> <span>Consolidado Automático de Compras & Separaciones</span>
</div> </div>
<p> <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> </p>
</div> </div>
@ -416,12 +636,18 @@ export default function AdminOrdersPage() {
key={group.name} key={group.name}
className="bg-neutral-900 border border-neutral-800 rounded-3xl p-6 shadow-xl space-y-4" 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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-neutral-800 pb-4">
<div> <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"> <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 Proveedor Mayorista
</span> </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> <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"> <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" /> <Phone className="w-3.5 h-3.5 text-emerald-400" />
@ -441,7 +667,6 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Items Table for this supplier */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs text-neutral-300"> <table className="w-full text-left text-xs text-neutral-300">
<thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px]"> <thead className="bg-neutral-950 text-neutral-400 font-bold uppercase text-[10px]">
@ -474,11 +699,10 @@ export default function AdminOrdersPage() {
</table> </table>
</div> </div>
{/* Send Batch WhatsApp Manifest Button */}
<div className="pt-2 flex justify-end"> <div className="pt-2 flex justify-end">
<button <button
onClick={() => handleSendSupplierBatchManifest(group.name, group.phone, group.entries)} 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" /> <MessageCircle className="w-4 h-4" />
<span>Enviar Manifiesto de {group.totalPairs} Pares por WhatsApp a {group.name}</span> <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 */} {/* TAB 3: RUTAS DE ENTREGA ORGANIZADAS POR DIRECCIONES & WAZE */}
{activeTab === 'routes' && ( {activeTab === 'routes' && (
<div className="space-y-6 animate-in fade-in"> <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 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>
<div className="flex items-center space-x-2 text-cyan-300 font-bold text-sm"> <div className="flex items-center space-x-2 text-purple-300 font-bold text-sm">
<Compass className="w-5 h-5 text-cyan-400" /> <Compass className="w-5 h-5 text-purple-400" />
<span>Hoja de Ruta & Navegación GPS (Waze / Google Maps)</span> <span>Hoja de Ruta & Navegación GPS (Waze / Google Maps)</span>
</div> </div>
<p className="text-xs text-neutral-300 mt-1 max-w-xl"> <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> </p>
</div> </div>
@ -518,7 +741,6 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Delivery Stops Sequential List */}
<div className="space-y-3"> <div className="space-y-3">
{pendingDeliveries.length === 0 ? ( {pendingDeliveries.length === 0 ? (
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500"> <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) => ( pendingDeliveries.map((delivery, index) => (
<div <div
key={delivery.id} 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"> <div className="flex items-start space-x-4">
{/* Stop Number Badge */} <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">
<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">
<span className="text-[10px] uppercase font-bold text-neutral-400 leading-none">Parada</span> <span className="text-[10px] uppercase font-bold text-neutral-400 leading-none">Parada</span>
<span className="text-lg leading-tight">#{index + 1}</span> <span className="text-lg leading-tight">#{index + 1}</span>
</div> </div>
@ -545,9 +765,8 @@ export default function AdminOrdersPage() {
{getStatusBadge(delivery.status)} {getStatusBadge(delivery.status)}
</div> </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"> <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> <span>{delivery.address} ({delivery.cityName})</span>
</p> </p>
@ -569,25 +788,29 @@ export default function AdminOrdersPage() {
</div> </div>
</div> </div>
{/* Right: Balance & 1-Click Waze / WhatsApp Actions */}
<div className="flex items-center gap-3 self-end lg:self-auto shrink-0"> <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"> <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-[10px] text-neutral-400 font-bold uppercase">Cobrar Contraentrega</p>
<p className="text-base font-black text-emerald-400">{formatCOP(delivery.balanceDue)}</p> <p className="text-base font-black text-emerald-400">{formatCOP(delivery.balanceDue)}</p>
</div> </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 <button
onClick={() => handleOpenWaze(delivery.address, delivery.cityName)} 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" title="Abrir ubicación directamente en Waze"
> >
<Compass className="w-4 h-4" /> <Compass className="w-4 h-4" />
<span>Waze</span> <span>Waze</span>
</button> </button>
{/* Google Maps GPS Button */}
<button <button
onClick={() => handleOpenGoogleMaps(delivery.address, delivery.cityName)} 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" 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" /> <Navigation className="w-4 h-4 text-emerald-400" />
</button> </button>
{/* WhatsApp Alert */}
<button <button
onClick={() => handleNotifyCustomerOnTheWay(delivery)} 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" 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>
</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> </div>
); );
} }

View File

@ -50,6 +50,11 @@ export default function AdminProductsPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => d.success && setProducts(d.products)) .then((d) => d.success && setProducts(d.products))
.catch(() => {}); .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) => { const handleToggleProductStatus = async (product: Product) => {

View 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>
);
}

View File

@ -28,3 +28,49 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 }); 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 });
}
}

View 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 });
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; 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'; import { useBrand } from '@/context/BrandContext';
interface MobileBottomDockProps { interface MobileBottomDockProps {
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
}) => { }) => {
const { currentBrand } = useBrand(); const { currentBrand } = useBrand();
const [activeTab, setActiveTab] = useState('home'); 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 handleWhatsApp = () => {
const text = encodeURIComponent( const text = encodeURIComponent(
@ -84,6 +96,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">WhatsApp</span> <span className="text-[10px] font-bold">WhatsApp</span>
</button> </button>
{!isInsideApp ? (
<button <button
onClick={onOpenInstallModal} onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-purple-400 hover:text-purple-300" 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> </div>
<span className="text-[10px] font-bold">App Móvil</span> <span className="text-[10px] font-bold">App Móvil</span>
</button> </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>
</div> </div>
); );

View File

@ -4,26 +4,43 @@ import React, { useState, useEffect } from 'react';
import Image from 'next/image'; import Image from 'next/image';
export const MobileSplashScreen: React.FC = () => { export const MobileSplashScreen: React.FC = () => {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(false);
const [isFading, setIsFading] = useState(false); const [isFading, setIsFading] = useState(false);
useEffect(() => { 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'); const hasShown = sessionStorage.getItem('dripzone_splash_shown');
if (hasShown) { if (hasShown) {
setIsVisible(false); setIsVisible(false);
return; 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(() => { const fadeTimer = setTimeout(() => {
setIsFading(true); setIsFading(true);
}, 3800); }, 2800);
const removeTimer = setTimeout(() => { const removeTimer = setTimeout(() => {
setIsVisible(false); setIsVisible(false);
sessionStorage.setItem('dripzone_splash_shown', 'true'); sessionStorage.setItem('dripzone_splash_shown', 'true');
}, 4600); }, 3400);
return () => { return () => {
clearTimeout(fadeTimer); clearTimeout(fadeTimer);

View File

@ -2334,17 +2334,42 @@ class DataService {
return this.products.length < initialLen; return this.products.length < initialLen;
} }
getSuppliers(): Supplier[] { getSuppliers(includeInactive: boolean = true): Supplier[] {
return this.suppliers; if (includeInactive) return this.suppliers;
return this.suppliers.filter((s) => s.isActive !== false);
} }
getSupplierById(id: string): Supplier | undefined { getSupplierById(id: string): Supplier | undefined {
return this.suppliers.find((s) => s.id === id); return this.suppliers.find((s) => s.id === id);
} }
addSupplier(supplier: Supplier): Supplier { addSupplier(supplier: Partial<Supplier>): Supplier {
this.suppliers.push(supplier); const newSupplier: Supplier = {
return 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[] { getCities(): City[] {
@ -2382,6 +2407,10 @@ class DataService {
return this.orders; return this.orders;
} }
getOrderById(id: string): Order | undefined {
return this.orders.find((o) => o.id === id);
}
addOrder(order: Order): Order { addOrder(order: Order): Order {
this.orders.unshift(order); this.orders.unshift(order);
return order; return order;
@ -2389,7 +2418,7 @@ class DataService {
createOrder(orderData: Partial<Order>): Order { createOrder(orderData: Partial<Order>): Order {
const newOrder: Order = { const newOrder: Order = {
id: `ord-${Date.now()}`, id: orderData.id || `ord-${Date.now()}`,
customerName: orderData.customerName || 'Cliente', customerName: orderData.customerName || 'Cliente',
customerPhone: orderData.customerPhone || '', customerPhone: orderData.customerPhone || '',
address: orderData.address || '', address: orderData.address || '',
@ -2399,18 +2428,43 @@ class DataService {
subtotal: orderData.subtotal || 0, subtotal: orderData.subtotal || 0,
shippingCost: orderData.shippingCost || 0, shippingCost: orderData.shippingCost || 0,
totalAmount: orderData.totalAmount || 0, totalAmount: orderData.totalAmount || 0,
depositAmount: orderData.depositAmount || 10000, depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000), balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega', paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
paymentMethodName: orderData.paymentMethodName || 'Contraentrega', paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
status: 'pending', status: orderData.status || 'pending',
notes: orderData.notes, notes: orderData.notes,
createdAt: new Date().toISOString(), createdAt: orderData.createdAt || new Date().toISOString(),
}; };
this.orders.unshift(newOrder); this.orders.unshift(newOrder);
return 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 { updateOrderStatus(orderId: string, status: Order['status']): Order | null {
const order = this.orders.find((o) => o.id === orderId); const order = this.orders.find((o) => o.id === orderId);
if (!order) return null; if (!order) return null;

View File

@ -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'; 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> { export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1200,
presence: 'composing',
},
textMessage: {
text: message, text: message,
}, delay: 1200,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 20000,
} }
); );
return response.status === 200 || response.status === 201; 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> { export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1500,
presence: 'composing',
},
mediaMessage: {
mediatype: 'image', mediatype: 'image',
caption, mimetype: 'image/jpeg',
caption: caption,
media: imageUrl, media: imageUrl,
}, delay: 1500,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 25000,
} }
); );
return response.status === 200 || response.status === 201; 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)}`) .map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
.join('\n\n'); .join('\n\n');
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉 return `🎉 *¡GRACIAS POR TU PEDIDO EN DRIPZONE LUXURY!* 🎉
📌 *No. de Orden:* #${order.id} 📌 *No. de Orden:* #${order.id}
👤 *Cliente:* ${order.customerName} 👤 *Cliente:* ${order.customerName}

View File

@ -22,7 +22,7 @@ async function sendApkToCarlos() {
const apkBuffer = fs.readFileSync(apkPath); const apkBuffer = fs.readFileSync(apkPath);
const base64Data = apkBuffer.toString('base64'); 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})...`); console.log(`📤 Enviando APK vía Evolution API a WhatsApp (+${TARGET_PHONE})...`);

View File

@ -15,6 +15,7 @@ import {
Store, Store,
Users, Users,
MessageSquare, MessageSquare,
Building2,
} from 'lucide-react'; } from 'lucide-react';
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, { 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/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/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store }, { href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,11 @@ export default function AdminProductsPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => d.success && setProducts(d.products)) .then((d) => d.success && setProducts(d.products))
.catch(() => {}); .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) => { const handleToggleProductStatus = async (product: Product) => {

View 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>
);
}

View File

@ -28,3 +28,49 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 }); 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 });
}
}

View 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 });
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; 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'; import { useBrand } from '@/context/BrandContext';
interface MobileBottomDockProps { interface MobileBottomDockProps {
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
}) => { }) => {
const { currentBrand } = useBrand(); const { currentBrand } = useBrand();
const [activeTab, setActiveTab] = useState('home'); 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 handleWhatsApp = () => {
const text = encodeURIComponent( const text = encodeURIComponent(
@ -31,6 +43,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
return ( 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="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"> <div className="flex items-center justify-around">
{/* 1. Inicio */}
<Link <Link
href="/" href="/"
onClick={() => setActiveTab('home')} onClick={() => setActiveTab('home')}
@ -42,6 +55,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">Inicio</span> <span className="text-[10px] font-bold">Inicio</span>
</Link> </Link>
{/* 2. Buscar */}
<button <button
onClick={() => { onClick={() => {
setActiveTab('search'); setActiveTab('search');
@ -58,6 +72,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">Buscar</span> <span className="text-[10px] font-bold">Buscar</span>
</button> </button>
{/* 3. Carrito con Badge */}
<button <button
onClick={() => { onClick={() => {
setActiveTab('cart'); setActiveTab('cart');
@ -76,6 +91,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold text-white">Carrito</span> <span className="text-[10px] font-bold text-white">Carrito</span>
</button> </button>
{/* 4. WhatsApp Asesoría */}
<button <button
onClick={handleWhatsApp} onClick={handleWhatsApp}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300" 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> <span className="text-[10px] font-bold">WhatsApp</span>
</button> </button>
{/* 5. App Móvil (solo en Web) vs Perfil/Admin (si ya está en la App) */}
{!isInsideApp ? (
<button <button
onClick={onOpenInstallModal} onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-orange-400 hover:text-orange-300" 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> </div>
<span className="text-[10px] font-bold">App Móvil</span> <span className="text-[10px] font-bold">App Móvil</span>
</button> </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>
</div> </div>
); );

View File

@ -2334,17 +2334,42 @@ class DataService {
return this.products.length < initialLen; return this.products.length < initialLen;
} }
getSuppliers(): Supplier[] { getSuppliers(includeInactive: boolean = true): Supplier[] {
return this.suppliers; if (includeInactive) return this.suppliers;
return this.suppliers.filter((s) => s.isActive !== false);
} }
getSupplierById(id: string): Supplier | undefined { getSupplierById(id: string): Supplier | undefined {
return this.suppliers.find((s) => s.id === id); return this.suppliers.find((s) => s.id === id);
} }
addSupplier(supplier: Supplier): Supplier { addSupplier(supplier: Partial<Supplier>): Supplier {
this.suppliers.push(supplier); const newSupplier: Supplier = {
return 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[] { getCities(): City[] {
@ -2382,6 +2407,10 @@ class DataService {
return this.orders; return this.orders;
} }
getOrderById(id: string): Order | undefined {
return this.orders.find((o) => o.id === id);
}
addOrder(order: Order): Order { addOrder(order: Order): Order {
this.orders.unshift(order); this.orders.unshift(order);
return order; return order;
@ -2389,7 +2418,7 @@ class DataService {
createOrder(orderData: Partial<Order>): Order { createOrder(orderData: Partial<Order>): Order {
const newOrder: Order = { const newOrder: Order = {
id: `ord-${Date.now()}`, id: orderData.id || `ord-${Date.now()}`,
customerName: orderData.customerName || 'Cliente', customerName: orderData.customerName || 'Cliente',
customerPhone: orderData.customerPhone || '', customerPhone: orderData.customerPhone || '',
address: orderData.address || '', address: orderData.address || '',
@ -2399,18 +2428,43 @@ class DataService {
subtotal: orderData.subtotal || 0, subtotal: orderData.subtotal || 0,
shippingCost: orderData.shippingCost || 0, shippingCost: orderData.shippingCost || 0,
totalAmount: orderData.totalAmount || 0, totalAmount: orderData.totalAmount || 0,
depositAmount: orderData.depositAmount || 10000, depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000), balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega', paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
paymentMethodName: orderData.paymentMethodName || 'Contraentrega', paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
status: 'pending', status: orderData.status || 'pending',
notes: orderData.notes, notes: orderData.notes,
createdAt: new Date().toISOString(), createdAt: orderData.createdAt || new Date().toISOString(),
}; };
this.orders.unshift(newOrder); this.orders.unshift(newOrder);
return 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 { updateOrderStatus(orderId: string, status: Order['status']): Order | null {
const order = this.orders.find((o) => o.id === orderId); const order = this.orders.find((o) => o.id === orderId);
if (!order) return null; if (!order) return null;

View File

@ -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'; 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> { export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1200,
presence: 'composing',
},
textMessage: {
text: message, text: message,
}, delay: 1200,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 20000,
} }
); );
return response.status === 200 || response.status === 201; 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> { export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1500,
presence: 'composing',
},
mediaMessage: {
mediatype: 'image', mediatype: 'image',
caption, mimetype: 'image/jpeg',
caption: caption,
media: imageUrl, media: imageUrl,
}, delay: 1500,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 25000,
} }
); );
return response.status === 200 || response.status === 201; 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)}`) .map((item: any) => `👟 *${item.productName}*\n 📏 Talla: *${item.size}* | Cantidad: ${item.quantity} | Valor: ${formatCOP(item.price)}`)
.join('\n\n'); .join('\n\n');
return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKERS CALI!* 🎉 return `🎉 *¡GRACIAS POR TU PEDIDO EN SNEAKY DROPS!* 🎉
📌 *No. de Orden:* #${order.id} 📌 *No. de Orden:* #${order.id}
👤 *Cliente:* ${order.customerName} 👤 *Cliente:* ${order.customerName}

View File

@ -15,6 +15,7 @@ import {
Store, Store,
Users, Users,
MessageSquare, MessageSquare,
Building2,
} from 'lucide-react'; } from 'lucide-react';
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@ -22,6 +23,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/admin', label: 'Resumen & KPIs', icon: LayoutDashboard }, { 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/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/usuarios', label: 'Control de Usuarios', icon: Users, badge: 'Roles & Accesos' },
{ href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store }, { href: '/admin/configuracion', label: 'Datos de la Tienda', icon: Store },

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,11 @@ export default function AdminProductsPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((d) => d.success && setProducts(d.products)) .then((d) => d.success && setProducts(d.products))
.catch(() => {}); .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) => { const handleToggleProductStatus = async (product: Product) => {

View 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>
);
}

View File

@ -28,3 +28,49 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: error.message }, { status: 400 }); 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 });
}
}

View 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 });
}
}

View File

@ -2,7 +2,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; 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'; import { useBrand } from '@/context/BrandContext';
interface MobileBottomDockProps { interface MobileBottomDockProps {
@ -20,6 +20,18 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
}) => { }) => {
const { currentBrand } = useBrand(); const { currentBrand } = useBrand();
const [activeTab, setActiveTab] = useState('home'); 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 handleWhatsApp = () => {
const text = encodeURIComponent( const text = encodeURIComponent(
@ -84,6 +96,7 @@ export const MobileBottomDock: React.FC<MobileBottomDockProps> = ({
<span className="text-[10px] font-bold">WhatsApp</span> <span className="text-[10px] font-bold">WhatsApp</span>
</button> </button>
{!isInsideApp ? (
<button <button
onClick={onOpenInstallModal} onClick={onOpenInstallModal}
className="flex flex-col items-center gap-0.5 cursor-pointer text-emerald-400 hover:text-emerald-300" 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> </div>
<span className="text-[10px] font-bold">App Móvil</span> <span className="text-[10px] font-bold">App Móvil</span>
</button> </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>
</div> </div>
); );

View File

@ -5,6 +5,8 @@ import { User } from '@/types';
interface AuthContextType { interface AuthContextType {
user: User | null; 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 }>; 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 }>; register: (data: { name: string; email: string; phone: string; city?: string; address?: string }) => Promise<{ success: boolean; error?: string }>;
logout: () => void; 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 { try {
const res = await fetch('/api/auth/login', { const res = await fetch('/api/auth/send-otp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier }), body: JSON.stringify({ identifier }),
}); });
const data = await res.json(); 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) { if (data.success && data.user) {
setUser(data.user); setUser(data.user);
localStorage.setItem('zapply_user', JSON.stringify(data.user)); localStorage.setItem('zapply_user', JSON.stringify(data.user));
setIsAuthModalOpen(false); setIsAuthModalOpen(false);
return { success: true }; 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) { } catch (e: any) {
return { success: false, error: e.message }; 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 }) => { const register = async (data: { name: string; email: string; phone: string; city?: string; address?: string }) => {
try { try {
const res = await fetch('/api/auth/register', { const res = await fetch('/api/auth/register', {
@ -73,19 +96,18 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
localStorage.removeItem('zapply_user'); localStorage.removeItem('zapply_user');
}; };
const openAuthModal = () => setIsAuthModalOpen(true);
const closeAuthModal = () => setIsAuthModalOpen(false);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
sendOtp,
verifyOtp,
login, login,
register, register,
logout, logout,
isAuthModalOpen, isAuthModalOpen,
openAuthModal, openAuthModal: () => setIsAuthModalOpen(true),
closeAuthModal, closeAuthModal: () => setIsAuthModalOpen(false),
}} }}
> >
{children} {children}

View File

@ -2334,17 +2334,42 @@ class DataService {
return this.products.length < initialLen; return this.products.length < initialLen;
} }
getSuppliers(): Supplier[] { getSuppliers(includeInactive: boolean = true): Supplier[] {
return this.suppliers; if (includeInactive) return this.suppliers;
return this.suppliers.filter((s) => s.isActive !== false);
} }
getSupplierById(id: string): Supplier | undefined { getSupplierById(id: string): Supplier | undefined {
return this.suppliers.find((s) => s.id === id); return this.suppliers.find((s) => s.id === id);
} }
addSupplier(supplier: Supplier): Supplier { addSupplier(supplier: Partial<Supplier>): Supplier {
this.suppliers.push(supplier); const newSupplier: Supplier = {
return 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[] { getCities(): City[] {
@ -2382,6 +2407,10 @@ class DataService {
return this.orders; return this.orders;
} }
getOrderById(id: string): Order | undefined {
return this.orders.find((o) => o.id === id);
}
addOrder(order: Order): Order { addOrder(order: Order): Order {
this.orders.unshift(order); this.orders.unshift(order);
return order; return order;
@ -2389,7 +2418,7 @@ class DataService {
createOrder(orderData: Partial<Order>): Order { createOrder(orderData: Partial<Order>): Order {
const newOrder: Order = { const newOrder: Order = {
id: `ord-${Date.now()}`, id: orderData.id || `ord-${Date.now()}`,
customerName: orderData.customerName || 'Cliente', customerName: orderData.customerName || 'Cliente',
customerPhone: orderData.customerPhone || '', customerPhone: orderData.customerPhone || '',
address: orderData.address || '', address: orderData.address || '',
@ -2399,18 +2428,43 @@ class DataService {
subtotal: orderData.subtotal || 0, subtotal: orderData.subtotal || 0,
shippingCost: orderData.shippingCost || 0, shippingCost: orderData.shippingCost || 0,
totalAmount: orderData.totalAmount || 0, totalAmount: orderData.totalAmount || 0,
depositAmount: orderData.depositAmount || 10000, depositAmount: orderData.depositAmount !== undefined ? orderData.depositAmount : 10000,
balanceDue: (orderData.totalAmount || 0) - (orderData.depositAmount || 10000), balanceDue: orderData.balanceDue !== undefined ? orderData.balanceDue : ((orderData.totalAmount || 0) - (orderData.depositAmount !== undefined ? orderData.depositAmount : 10000)),
paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega', paymentMethodId: orderData.paymentMethodId || 'pay-contraentrega',
paymentMethodName: orderData.paymentMethodName || 'Contraentrega', paymentMethodName: orderData.paymentMethodName || 'Contraentrega',
status: 'pending', status: orderData.status || 'pending',
notes: orderData.notes, notes: orderData.notes,
createdAt: new Date().toISOString(), createdAt: orderData.createdAt || new Date().toISOString(),
}; };
this.orders.unshift(newOrder); this.orders.unshift(newOrder);
return 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 { updateOrderStatus(orderId: string, status: Order['status']): Order | null {
const order = this.orders.find((o) => o.id === orderId); const order = this.orders.find((o) => o.id === orderId);
if (!order) return null; if (!order) return null;

View File

@ -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'; 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> { export async function sendWhatsAppMessage(toPhone: string, message: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; 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 { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendText/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1200,
presence: 'composing',
},
textMessage: {
text: message, text: message,
}, delay: 1200,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 20000,
} }
); );
return response.status === 200 || response.status === 201; 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> { export async function sendWhatsAppMedia(toPhone: string, imageUrl: string, caption: string): Promise<boolean> {
const cleanPhone = toPhone.replace(/\D/g, ''); const cleanDigits = toPhone.replace(/\D/g, '');
const recipient = cleanPhone.startsWith('57') ? `${cleanPhone}@s.whatsapp.net` : `57${cleanPhone}@s.whatsapp.net`; if (!cleanDigits) return false;
const recipient = cleanDigits.startsWith('57') ? cleanDigits : `57${cleanDigits}`;
try { try {
const response = await axios.post( const response = await axios.post(
`${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`, `${EVOLUTION_API_URL}/message/sendMedia/${EVOLUTION_INSTANCE_NAME}`,
{ {
number: recipient, number: recipient,
options: {
delay: 1500,
presence: 'composing',
},
mediaMessage: {
mediatype: 'image', mediatype: 'image',
caption, mimetype: 'image/jpeg',
caption: caption,
media: imageUrl, media: imageUrl,
}, delay: 1500,
}, },
{ {
headers: { headers: {
apikey: EVOLUTION_API_KEY, apikey: EVOLUTION_API_KEY,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
timeout: 25000,
} }
); );
return response.status === 200 || response.status === 201; return response.status === 200 || response.status === 201;