feat(suppliers-orders): sincronizar modulo de proveedores, gestion de pedidos, api evolution v2 y compilacion apk en todas las aplicaciones
This commit is contained in:
parent
4e1476e1a9
commit
e675ec3641
@ -15,6 +15,7 @@ import {
|
|||||||
Store,
|
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
@ -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) => {
|
||||||
|
|||||||
593
almeira-store-app/src/app/admin/proveedores/page.tsx
Normal file
593
almeira-store-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Edit2,
|
||||||
|
Power,
|
||||||
|
Package,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
User,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Supplier, Product } from '@/types';
|
||||||
|
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export default function AdminSuppliersPage() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [contactPerson, setContactPerson] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
fetchProducts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.suppliers) {
|
||||||
|
setSuppliers(data.suppliers);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching suppliers:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/products?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.products) {
|
||||||
|
setProducts(data.products);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching products:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingSupplier(null);
|
||||||
|
setName('');
|
||||||
|
setPhone('57');
|
||||||
|
setContactPerson('');
|
||||||
|
setAddress('');
|
||||||
|
setNotes('');
|
||||||
|
setIsActive(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier);
|
||||||
|
setName(supplier.name);
|
||||||
|
setPhone(supplier.phone);
|
||||||
|
setContactPerson(supplier.contactPerson || '');
|
||||||
|
setAddress(supplier.address || '');
|
||||||
|
setNotes(supplier.notes || '');
|
||||||
|
setIsActive(supplier.isActive !== false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (supplier: Supplier) => {
|
||||||
|
const nextStatus = !supplier.isActive;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setSuppliers((prev) =>
|
||||||
|
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: supplier.id,
|
||||||
|
updates: { isActive: nextStatus },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(
|
||||||
|
nextStatus
|
||||||
|
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||||
|
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||||
|
);
|
||||||
|
setTimeout(() => setToast(null), 4500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error toggling supplier active status:', e);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingSupplier) {
|
||||||
|
// Edit existing
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingSupplier.id,
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||||
|
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Almeira Store.`
|
||||||
|
);
|
||||||
|
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||||
|
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||||
|
|
||||||
|
const filteredSuppliers = suppliers.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.phone.includes(search) ||
|
||||||
|
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||||
|
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||||
|
return products.filter(
|
||||||
|
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||||
|
).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||||
|
<Building2 className="w-6 h-6 text-cyan-400" />
|
||||||
|
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nuevo Proveedor</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="bg-cyan-950/90 border border-cyan-500 text-cyan-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||||
|
<span>{toast}</span>
|
||||||
|
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Total Proveedores</span>
|
||||||
|
<Building2 className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Bodegas y mayoristas registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Proveedores Activos</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Desactivados / Históricos</span>
|
||||||
|
<XCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Historial preservado 100% intacto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Zapatillas Vinculadas</span>
|
||||||
|
<Package className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-purple-300">{products.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Modelos en catálogo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner About Historical Safety */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 via-slate-900 to-cyan-950/40 border border-cyan-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-slate-300 shadow-md">
|
||||||
|
<Info className="w-5 h-5 text-cyan-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-white">
|
||||||
|
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||||
|
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos ({suppliers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'active'
|
||||||
|
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||||
|
: 'text-slate-400 hover:text-emerald-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
|
<span>Activos ({activeSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'inactive'
|
||||||
|
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||||
|
: 'text-slate-400 hover:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-cyan-400"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="col-span-full bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center text-slate-500 space-y-2">
|
||||||
|
<Building2 className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||||
|
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier) => {
|
||||||
|
const isSupActive = supplier.isActive !== false;
|
||||||
|
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={supplier.id}
|
||||||
|
className={`bg-slate-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||||
|
isSupActive
|
||||||
|
? 'border-slate-800 hover:border-cyan-500/40'
|
||||||
|
: 'border-slate-800/80 opacity-75 bg-slate-950/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Top: Status & Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(supplier)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||||
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||||
|
>
|
||||||
|
<Power className="w-3 h-3" />
|
||||||
|
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||||
|
|
||||||
|
{/* Details List */}
|
||||||
|
<div className="space-y-1.5 text-xs pt-1">
|
||||||
|
{supplier.contactPerson && (
|
||||||
|
<p className="text-slate-300 flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{supplier.address && (
|
||||||
|
<p className="text-slate-400 flex items-center gap-1.5 text-[11px]">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||||
|
<span>{supplier.address}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supplier.notes && (
|
||||||
|
<p className="text-slate-400 italic text-[11px] bg-slate-950 p-2 rounded-xl border border-slate-800 mt-1">
|
||||||
|
"{supplier.notes}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Bottom: Metrics & Actions */}
|
||||||
|
<div className="pt-3 border-t border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Package className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Modelos asignados:</span>
|
||||||
|
</span>
|
||||||
|
<strong className="text-white bg-slate-950 px-2 py-0.5 rounded-lg border border-slate-800">
|
||||||
|
{linkedProductsCount} pares
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenWhatsApp(supplier)}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditModal(supplier)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Modificar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear / Editar Proveedor */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-cyan-400" />
|
||||||
|
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400 font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="ej: 573167455877"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Persona de Contacto</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. Asesor Despachos"
|
||||||
|
value={contactPerson}
|
||||||
|
onChange={(e) => setContactPerson(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-cyan-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{isActive
|
||||||
|
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||||
|
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateOrder(targetId, payload);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, order: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = dataService.deleteOrder(id);
|
||||||
|
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
almeira-store-app/src/app/api/suppliers/route.ts
Normal file
71
almeira-store-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dataService } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||||
|
const suppliers = dataService.getSuppliers(includeInactive);
|
||||||
|
return NextResponse.json({ success: true, suppliers });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const newSupplier = dataService.addSupplier(body);
|
||||||
|
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateSupplier(targetId, payload);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, supplier: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||||
|
const success = dataService.deleteSupplier(id);
|
||||||
|
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -24,30 +26,51 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
setUser(JSON.parse(saved));
|
setUser(JSON.parse(saved));
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
593
dripzone-app/src/app/admin/proveedores/page.tsx
Normal file
593
dripzone-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Edit2,
|
||||||
|
Power,
|
||||||
|
Package,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
User,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Supplier, Product } from '@/types';
|
||||||
|
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export default function AdminSuppliersPage() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [contactPerson, setContactPerson] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
fetchProducts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.suppliers) {
|
||||||
|
setSuppliers(data.suppliers);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching suppliers:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/products?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.products) {
|
||||||
|
setProducts(data.products);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching products:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingSupplier(null);
|
||||||
|
setName('');
|
||||||
|
setPhone('57');
|
||||||
|
setContactPerson('');
|
||||||
|
setAddress('');
|
||||||
|
setNotes('');
|
||||||
|
setIsActive(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier);
|
||||||
|
setName(supplier.name);
|
||||||
|
setPhone(supplier.phone);
|
||||||
|
setContactPerson(supplier.contactPerson || '');
|
||||||
|
setAddress(supplier.address || '');
|
||||||
|
setNotes(supplier.notes || '');
|
||||||
|
setIsActive(supplier.isActive !== false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (supplier: Supplier) => {
|
||||||
|
const nextStatus = !supplier.isActive;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setSuppliers((prev) =>
|
||||||
|
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: supplier.id,
|
||||||
|
updates: { isActive: nextStatus },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(
|
||||||
|
nextStatus
|
||||||
|
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||||
|
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||||
|
);
|
||||||
|
setTimeout(() => setToast(null), 4500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error toggling supplier active status:', e);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingSupplier) {
|
||||||
|
// Edit existing
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingSupplier.id,
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||||
|
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de DripZone.`
|
||||||
|
);
|
||||||
|
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||||
|
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||||
|
|
||||||
|
const filteredSuppliers = suppliers.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.phone.includes(search) ||
|
||||||
|
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||||
|
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||||
|
return products.filter(
|
||||||
|
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||||
|
).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||||
|
<Building2 className="w-6 h-6 text-purple-400" />
|
||||||
|
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-neutral-400 mt-1">
|
||||||
|
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nuevo Proveedor</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="bg-purple-950/90 border border-purple-500 text-purple-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||||
|
<span>{toast}</span>
|
||||||
|
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-neutral-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Total Proveedores</span>
|
||||||
|
<Building2 className="w-4 h-4 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400">Bodegas y mayoristas registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Proveedores Activos</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Desactivados / Históricos</span>
|
||||||
|
<XCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400">Historial preservado 100% intacto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-neutral-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Zapatillas Vinculadas</span>
|
||||||
|
<Package className="w-4 h-4 text-fuchsia-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-fuchsia-300">{products.length}</p>
|
||||||
|
<p className="text-[10px] text-neutral-400">Modelos en catálogo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner About Historical Safety */}
|
||||||
|
<div className="bg-gradient-to-r from-neutral-900 via-neutral-900 to-purple-950/40 border border-purple-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-neutral-300 shadow-md">
|
||||||
|
<Info className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-white">
|
||||||
|
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-neutral-400">
|
||||||
|
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center space-x-1.5 bg-neutral-900 border border-neutral-800 p-1 rounded-2xl overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||||
|
statusFilter === 'all' ? 'bg-white text-neutral-950 shadow' : 'text-neutral-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos ({suppliers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'active'
|
||||||
|
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||||
|
: 'text-neutral-400 hover:text-emerald-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
|
<span>Activos ({activeSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'inactive'
|
||||||
|
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||||
|
: 'text-neutral-400 hover:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-neutral-900 border border-neutral-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-purple-400"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="col-span-full bg-neutral-900 border border-neutral-800 rounded-3xl p-12 text-center text-neutral-500 space-y-2">
|
||||||
|
<Building2 className="w-12 h-12 text-neutral-600 mx-auto" />
|
||||||
|
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||||
|
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier) => {
|
||||||
|
const isSupActive = supplier.isActive !== false;
|
||||||
|
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={supplier.id}
|
||||||
|
className={`bg-neutral-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||||
|
isSupActive
|
||||||
|
? 'border-neutral-800 hover:border-purple-500/40'
|
||||||
|
: 'border-neutral-800/80 opacity-75 bg-neutral-950/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Top: Status & Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(supplier)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||||
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||||
|
>
|
||||||
|
<Power className="w-3 h-3" />
|
||||||
|
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||||
|
|
||||||
|
{/* Details List */}
|
||||||
|
<div className="space-y-1.5 text-xs pt-1">
|
||||||
|
{supplier.contactPerson && (
|
||||||
|
<p className="text-neutral-300 flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-purple-400 shrink-0" />
|
||||||
|
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-neutral-300 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{supplier.address && (
|
||||||
|
<p className="text-neutral-400 flex items-center gap-1.5 text-[11px]">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-pink-400 shrink-0" />
|
||||||
|
<span>{supplier.address}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supplier.notes && (
|
||||||
|
<p className="text-neutral-400 italic text-[11px] bg-neutral-950 p-2 rounded-xl border border-neutral-800 mt-1">
|
||||||
|
"{supplier.notes}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Bottom: Metrics & Actions */}
|
||||||
|
<div className="pt-3 border-t border-neutral-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-neutral-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Package className="w-3.5 h-3.5 text-fuchsia-400" />
|
||||||
|
<span>Modelos asignados:</span>
|
||||||
|
</span>
|
||||||
|
<strong className="text-white bg-neutral-950 px-2 py-0.5 rounded-lg border border-neutral-800">
|
||||||
|
{linkedProductsCount} pares
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenWhatsApp(supplier)}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditModal(supplier)}
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
<span>Modificar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear / Editar Proveedor */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-800 pb-3">
|
||||||
|
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-purple-400" />
|
||||||
|
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="text-neutral-400 hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-neutral-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400 font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="ej: 573167455877"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-neutral-400 font-bold mb-1">Persona de Contacto</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. Asesor Despachos"
|
||||||
|
value={contactPerson}
|
||||||
|
onChange={(e) => setContactPerson(e.target.value)}
|
||||||
|
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-neutral-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-neutral-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full bg-neutral-950 border border-neutral-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-purple-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-neutral-950 p-3 rounded-2xl border border-neutral-800 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||||
|
<p className="text-[10px] text-neutral-400">
|
||||||
|
{isActive
|
||||||
|
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||||
|
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-neutral-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2 border-t border-neutral-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="bg-neutral-800 hover:bg-neutral-700 text-neutral-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-gradient-to-r from-purple-600 to-fuchsia-600 hover:from-purple-500 hover:to-fuchsia-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateOrder(targetId, payload);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, order: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = dataService.deleteOrder(id);
|
||||||
|
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
dripzone-app/src/app/api/suppliers/route.ts
Normal file
71
dripzone-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dataService } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||||
|
const suppliers = dataService.getSuppliers(includeInactive);
|
||||||
|
return NextResponse.json({ success: true, suppliers });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const newSupplier = dataService.addSupplier(body);
|
||||||
|
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateSupplier(targetId, payload);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, supplier: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||||
|
const success = dataService.deleteSupplier(id);
|
||||||
|
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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})...`);
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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) => {
|
||||||
|
|||||||
593
sneaky-app/src/app/admin/proveedores/page.tsx
Normal file
593
sneaky-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Edit2,
|
||||||
|
Power,
|
||||||
|
Package,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
User,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Supplier, Product } from '@/types';
|
||||||
|
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export default function AdminSuppliersPage() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [contactPerson, setContactPerson] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
fetchProducts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.suppliers) {
|
||||||
|
setSuppliers(data.suppliers);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching suppliers:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/products?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.products) {
|
||||||
|
setProducts(data.products);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching products:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingSupplier(null);
|
||||||
|
setName('');
|
||||||
|
setPhone('57');
|
||||||
|
setContactPerson('');
|
||||||
|
setAddress('');
|
||||||
|
setNotes('');
|
||||||
|
setIsActive(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier);
|
||||||
|
setName(supplier.name);
|
||||||
|
setPhone(supplier.phone);
|
||||||
|
setContactPerson(supplier.contactPerson || '');
|
||||||
|
setAddress(supplier.address || '');
|
||||||
|
setNotes(supplier.notes || '');
|
||||||
|
setIsActive(supplier.isActive !== false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (supplier: Supplier) => {
|
||||||
|
const nextStatus = !supplier.isActive;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setSuppliers((prev) =>
|
||||||
|
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: supplier.id,
|
||||||
|
updates: { isActive: nextStatus },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(
|
||||||
|
nextStatus
|
||||||
|
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||||
|
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||||
|
);
|
||||||
|
setTimeout(() => setToast(null), 4500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error toggling supplier active status:', e);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingSupplier) {
|
||||||
|
// Edit existing
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingSupplier.id,
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||||
|
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Sneaky Drops.`
|
||||||
|
);
|
||||||
|
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||||
|
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||||
|
|
||||||
|
const filteredSuppliers = suppliers.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.phone.includes(search) ||
|
||||||
|
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||||
|
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||||
|
return products.filter(
|
||||||
|
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||||
|
).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||||
|
<Building2 className="w-6 h-6 text-orange-400" />
|
||||||
|
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-zinc-400 mt-1">
|
||||||
|
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nuevo Proveedor</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="bg-orange-950/90 border border-orange-500 text-orange-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||||
|
<span>{toast}</span>
|
||||||
|
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-zinc-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Total Proveedores</span>
|
||||||
|
<Building2 className="w-4 h-4 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-zinc-400">Bodegas y mayoristas registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Proveedores Activos</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Desactivados / Históricos</span>
|
||||||
|
<XCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-zinc-400">Historial preservado 100% intacto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-zinc-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Zapatillas Vinculadas</span>
|
||||||
|
<Package className="w-4 h-4 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-red-300">{products.length}</p>
|
||||||
|
<p className="text-[10px] text-zinc-400">Modelos en catálogo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner About Historical Safety */}
|
||||||
|
<div className="bg-gradient-to-r from-zinc-900 via-zinc-900 to-orange-950/40 border border-orange-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-zinc-300 shadow-md">
|
||||||
|
<Info className="w-5 h-5 text-orange-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-white">
|
||||||
|
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-zinc-400">
|
||||||
|
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center space-x-1.5 bg-zinc-900 border border-zinc-800 p-1 rounded-2xl overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||||
|
statusFilter === 'all' ? 'bg-white text-zinc-950 shadow' : 'text-zinc-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos ({suppliers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'active'
|
||||||
|
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||||
|
: 'text-zinc-400 hover:text-emerald-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
|
<span>Activos ({activeSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'inactive'
|
||||||
|
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||||
|
: 'text-zinc-400 hover:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-zinc-900 border border-zinc-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-zinc-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="col-span-full bg-zinc-900 border border-zinc-800 rounded-3xl p-12 text-center text-zinc-500 space-y-2">
|
||||||
|
<Building2 className="w-12 h-12 text-zinc-600 mx-auto" />
|
||||||
|
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||||
|
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier) => {
|
||||||
|
const isSupActive = supplier.isActive !== false;
|
||||||
|
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={supplier.id}
|
||||||
|
className={`bg-zinc-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||||
|
isSupActive
|
||||||
|
? 'border-zinc-800 hover:border-orange-500/40'
|
||||||
|
: 'border-zinc-800/80 opacity-75 bg-zinc-950/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Top: Status & Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(supplier)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||||
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||||
|
>
|
||||||
|
<Power className="w-3 h-3" />
|
||||||
|
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||||
|
|
||||||
|
{/* Details List */}
|
||||||
|
<div className="space-y-1.5 text-xs pt-1">
|
||||||
|
{supplier.contactPerson && (
|
||||||
|
<p className="text-zinc-300 flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-orange-400 shrink-0" />
|
||||||
|
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-zinc-300 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{supplier.address && (
|
||||||
|
<p className="text-zinc-400 flex items-center gap-1.5 text-[11px]">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-red-400 shrink-0" />
|
||||||
|
<span>{supplier.address}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supplier.notes && (
|
||||||
|
<p className="text-zinc-400 italic text-[11px] bg-zinc-950 p-2 rounded-xl border border-zinc-800 mt-1">
|
||||||
|
"{supplier.notes}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Bottom: Metrics & Actions */}
|
||||||
|
<div className="pt-3 border-t border-zinc-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Package className="w-3.5 h-3.5 text-red-400" />
|
||||||
|
<span>Modelos asignados:</span>
|
||||||
|
</span>
|
||||||
|
<strong className="text-white bg-zinc-950 px-2 py-0.5 rounded-lg border border-zinc-800">
|
||||||
|
{linkedProductsCount} pares
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenWhatsApp(supplier)}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditModal(supplier)}
|
||||||
|
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
<span>Modificar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear / Editar Proveedor */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800 pb-3">
|
||||||
|
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-orange-400" />
|
||||||
|
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="text-zinc-400 hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-zinc-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400 font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="ej: 573167455877"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-zinc-400 font-bold mb-1">Persona de Contacto</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. Asesor Despachos"
|
||||||
|
value={contactPerson}
|
||||||
|
onChange={(e) => setContactPerson(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-zinc-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-zinc-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-orange-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-zinc-950 p-3 rounded-2xl border border-zinc-800 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||||
|
<p className="text-[10px] text-zinc-400">
|
||||||
|
{isActive
|
||||||
|
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||||
|
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-zinc-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2 border-t border-zinc-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-gradient-to-r from-red-600 to-orange-500 hover:from-red-500 hover:to-orange-400 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateOrder(targetId, payload);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, order: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = dataService.deleteOrder(id);
|
||||||
|
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
sneaky-app/src/app/api/suppliers/route.ts
Normal file
71
sneaky-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dataService } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||||
|
const suppliers = dataService.getSuppliers(includeInactive);
|
||||||
|
return NextResponse.json({ success: true, suppliers });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const newSupplier = dataService.addSupplier(body);
|
||||||
|
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateSupplier(targetId, payload);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, supplier: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||||
|
const success = dataService.deleteSupplier(id);
|
||||||
|
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
@ -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) => {
|
||||||
|
|||||||
593
zapply-app/src/app/admin/proveedores/page.tsx
Normal file
593
zapply-app/src/app/admin/proveedores/page.tsx
Normal file
@ -0,0 +1,593 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Phone,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
Edit2,
|
||||||
|
Power,
|
||||||
|
Package,
|
||||||
|
ShieldCheck,
|
||||||
|
FileText,
|
||||||
|
User,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Supplier, Product } from '@/types';
|
||||||
|
import { INITIAL_SUPPLIERS } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export default function AdminSuppliersPage() {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>(INITIAL_SUPPLIERS);
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [contactPerson, setContactPerson] = useState('');
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
fetchProducts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.suppliers) {
|
||||||
|
setSuppliers(data.suppliers);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching suppliers:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/products?all=true');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.products) {
|
||||||
|
setProducts(data.products);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching products:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreateModal = () => {
|
||||||
|
setEditingSupplier(null);
|
||||||
|
setName('');
|
||||||
|
setPhone('57');
|
||||||
|
setContactPerson('');
|
||||||
|
setAddress('');
|
||||||
|
setNotes('');
|
||||||
|
setIsActive(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditModal = (supplier: Supplier) => {
|
||||||
|
setEditingSupplier(supplier);
|
||||||
|
setName(supplier.name);
|
||||||
|
setPhone(supplier.phone);
|
||||||
|
setContactPerson(supplier.contactPerson || '');
|
||||||
|
setAddress(supplier.address || '');
|
||||||
|
setNotes(supplier.notes || '');
|
||||||
|
setIsActive(supplier.isActive !== false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = async (supplier: Supplier) => {
|
||||||
|
const nextStatus = !supplier.isActive;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setSuppliers((prev) =>
|
||||||
|
prev.map((s) => (s.id === supplier.id ? { ...s, isActive: nextStatus } : s))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: supplier.id,
|
||||||
|
updates: { isActive: nextStatus },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(
|
||||||
|
nextStatus
|
||||||
|
? `🟢 Proveedor "${supplier.name}" ACTIVADO exitosamente.`
|
||||||
|
: `⚪ Proveedor "${supplier.name}" DESACTIVADO. Su información histórica y pedidos continúan 100% seguros.`
|
||||||
|
);
|
||||||
|
setTimeout(() => setToast(null), 4500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error toggling supplier active status:', e);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSupplier = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
const finalPhone = cleanPhone.startsWith('57') ? cleanPhone : `57${cleanPhone}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingSupplier) {
|
||||||
|
// Edit existing
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingSupplier.id,
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✅ Proveedor "${name}" actualizado con éxito.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const res = await fetch('/api/suppliers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
phone: finalPhone,
|
||||||
|
contactPerson,
|
||||||
|
address,
|
||||||
|
notes,
|
||||||
|
isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setToast(`✨ Proveedor "${name}" registrado correctamente.`);
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchSuppliers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenWhatsApp = (supplier: Supplier) => {
|
||||||
|
const cleanPhone = supplier.phone.startsWith('57') ? supplier.phone : `57${supplier.phone}`;
|
||||||
|
const text = encodeURIComponent(
|
||||||
|
`👋 Hola ${supplier.contactPerson || supplier.name}! Te escribo desde el panel de control de Zapply Cali.`
|
||||||
|
);
|
||||||
|
window.open(`https://wa.me/${cleanPhone}?text=${text}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
const activeSuppliers = suppliers.filter((s) => s.isActive !== false);
|
||||||
|
const inactiveSuppliers = suppliers.filter((s) => s.isActive === false);
|
||||||
|
|
||||||
|
const filteredSuppliers = suppliers.filter((s) => {
|
||||||
|
const matchesSearch =
|
||||||
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.phone.includes(search) ||
|
||||||
|
(s.contactPerson && s.contactPerson.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.address && s.address.toLowerCase().includes(search.toLowerCase())) ||
|
||||||
|
(s.notes && s.notes.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
|
if (!matchesSearch) return false;
|
||||||
|
if (statusFilter === 'active' && s.isActive === false) return false;
|
||||||
|
if (statusFilter === 'inactive' && s.isActive !== false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getProductCountForSupplier = (supplierId: string, supplierName: string) => {
|
||||||
|
return products.filter(
|
||||||
|
(p) => p.supplierId === supplierId || (p.supplierName && p.supplierName.toLowerCase() === supplierName.toLowerCase())
|
||||||
|
).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-white flex items-center gap-2">
|
||||||
|
<Building2 className="w-6 h-6 text-emerald-400" />
|
||||||
|
<span>Proveedores & Bodegas Aliadas ({suppliers.length})</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
Activa o desactiva proveedores en 1 clic <strong>sin perder el histórico de pedidos, productos ni trazabilidad logística</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreateModal}
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white font-bold text-xs px-4 py-2.5 rounded-2xl flex items-center space-x-2 shadow-lg cursor-pointer transition-all active:scale-95 self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>Nuevo Proveedor</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="bg-emerald-950/90 border border-emerald-500 text-emerald-200 px-4 py-3 rounded-2xl text-xs flex items-center justify-between shadow-xl animate-in fade-in">
|
||||||
|
<span>{toast}</span>
|
||||||
|
<button onClick={() => setToast(null)} className="font-bold text-white cursor-pointer ml-3">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3.5">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Total Proveedores</span>
|
||||||
|
<Building2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-white">{suppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Bodegas y mayoristas registrados</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-emerald-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-emerald-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Proveedores Activos</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-emerald-300">{activeSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-emerald-400/80">Disponibles para nuevas zapatillas y pedidos</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-amber-500/30 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-amber-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Desactivados / Históricos</span>
|
||||||
|
<XCircle className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-amber-300">{inactiveSuppliers.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Historial preservado 100% intacto</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900 border border-slate-800 p-4 rounded-3xl space-y-1 shadow-lg">
|
||||||
|
<div className="flex items-center justify-between text-slate-400 text-xs font-bold uppercase tracking-wider">
|
||||||
|
<span>Zapatillas Vinculadas</span>
|
||||||
|
<Package className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-black text-cyan-300">{products.length}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">Modelos en catálogo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner About Historical Safety */}
|
||||||
|
<div className="bg-gradient-to-r from-slate-900 via-slate-900 to-emerald-950/40 border border-emerald-500/20 rounded-3xl p-4 flex items-start space-x-3 text-xs text-slate-300 shadow-md">
|
||||||
|
<Info className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-bold text-white">
|
||||||
|
🛡️ Trazabilidad & Protección Histórica Garantizada
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Al desactivar un proveedor, <strong>ningún pedido pasado se altera ni se pierde</strong>. Los manifiestos de despacho, reportes financieros y costos mayoristas de pedidos anteriores conservan su información original. El proveedor simplemente se oculta para nuevas asignaciones de zapatillas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter and Search Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center space-x-1.5 bg-slate-900 border border-slate-800 p-1 rounded-2xl overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all ${
|
||||||
|
statusFilter === 'all' ? 'bg-white text-slate-950 shadow' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todos ({suppliers.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('active')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'active'
|
||||||
|
? 'bg-emerald-500/30 border border-emerald-500/50 text-emerald-300 shadow'
|
||||||
|
: 'text-slate-400 hover:text-emerald-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
||||||
|
<span>Activos ({activeSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('inactive')}
|
||||||
|
className={`text-xs font-bold px-3.5 py-1.5 rounded-xl cursor-pointer transition-all flex items-center gap-1.5 ${
|
||||||
|
statusFilter === 'inactive'
|
||||||
|
? 'bg-amber-500/30 border border-amber-500/50 text-amber-300 shadow'
|
||||||
|
: 'text-slate-400 hover:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||||
|
<span>Inactivos ({inactiveSuppliers.length})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-xs w-full">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar proveedor, teléfono o bodega..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-slate-900 border border-slate-800 rounded-xl pl-9 pr-3.5 py-2 text-xs text-white outline-hidden focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supplier Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredSuppliers.length === 0 ? (
|
||||||
|
<div className="col-span-full bg-slate-900 border border-slate-800 rounded-3xl p-12 text-center text-slate-500 space-y-2">
|
||||||
|
<Building2 className="w-12 h-12 text-slate-600 mx-auto" />
|
||||||
|
<p className="font-bold text-white text-sm">No se encontraron proveedores</p>
|
||||||
|
<p className="text-xs">Prueba con otro término de búsqueda o cambia los filtros.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredSuppliers.map((supplier) => {
|
||||||
|
const isSupActive = supplier.isActive !== false;
|
||||||
|
const linkedProductsCount = getProductCountForSupplier(supplier.id, supplier.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={supplier.id}
|
||||||
|
className={`bg-slate-900 border rounded-3xl p-5 shadow-xl transition-all flex flex-col justify-between space-y-4 ${
|
||||||
|
isSupActive
|
||||||
|
? 'border-slate-800 hover:border-emerald-500/40'
|
||||||
|
: 'border-slate-800/80 opacity-75 bg-slate-950/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Card Top: Status & Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2.5 py-0.5 rounded-full border flex items-center gap-1 ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-emerald-500/20 border-emerald-500/40 text-emerald-300'
|
||||||
|
: 'bg-amber-500/20 border-amber-500/40 text-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
isSupActive ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{isSupActive ? 'Activo para Pedidos' : 'Inactivo (Histórico Seguro)'}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(supplier)}
|
||||||
|
className={`text-[11px] font-bold px-2.5 py-1 rounded-xl border flex items-center gap-1 cursor-pointer transition-all ${
|
||||||
|
isSupActive
|
||||||
|
? 'bg-red-500/10 border-red-500/30 text-red-400 hover:bg-red-500/20'
|
||||||
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
title={isSupActive ? 'Desactivar proveedor (conserva historial)' : 'Reactivar proveedor'}
|
||||||
|
>
|
||||||
|
<Power className="w-3 h-3" />
|
||||||
|
<span>{isSupActive ? 'Desactivar' : 'Activar'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-black text-white text-base leading-snug">{supplier.name}</h3>
|
||||||
|
|
||||||
|
{/* Details List */}
|
||||||
|
<div className="space-y-1.5 text-xs pt-1">
|
||||||
|
{supplier.contactPerson && (
|
||||||
|
<p className="text-slate-300 flex items-center gap-1.5">
|
||||||
|
<User className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span>Contacto: <strong className="text-white">{supplier.contactPerson}</strong></span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span className="font-mono text-emerald-300 font-semibold">{supplier.phone}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{supplier.address && (
|
||||||
|
<p className="text-slate-400 flex items-center gap-1.5 text-[11px]">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
<span>{supplier.address}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supplier.notes && (
|
||||||
|
<p className="text-slate-400 italic text-[11px] bg-slate-950 p-2 rounded-xl border border-slate-800 mt-1">
|
||||||
|
"{supplier.notes}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Bottom: Metrics & Actions */}
|
||||||
|
<div className="pt-3 border-t border-slate-800 space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Package className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
<span>Modelos asignados:</span>
|
||||||
|
</span>
|
||||||
|
<strong className="text-white bg-slate-950 px-2 py-0.5 rounded-lg border border-slate-800">
|
||||||
|
{linkedProductsCount} pares
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenWhatsApp(supplier)}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer shadow transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>WhatsApp</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEditModal(supplier)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-200 font-bold text-xs py-2 px-3 rounded-xl flex items-center justify-center gap-1.5 cursor-pointer transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
<span>Modificar</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Crear / Editar Proveedor */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 animate-in fade-in">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-3xl max-w-lg w-full p-6 space-y-4 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||||
|
<h3 className="font-black text-white text-base flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span>{editingSupplier ? 'Modificar Proveedor' : 'Registrar Nuevo Proveedor'}</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="text-slate-400 hover:text-white cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveSupplier} className="space-y-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Nombre Comercial / Bodega *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ej. 💎 DIFUSIÓN JOVIC STORE COLOMBIA"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400 font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-emerald-400 font-bold mb-1">Celular / WhatsApp *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="ej: 573167455877"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-emerald-300 outline-hidden focus:border-emerald-400 font-mono font-bold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Persona de Contacto</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. Asesor Despachos"
|
||||||
|
value={contactPerson}
|
||||||
|
onChange={(e) => setContactPerson(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Ubicación / Dirección de la Bodega</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Ej. CC MÍO Local 3B / San Andresito Cali"
|
||||||
|
value={address}
|
||||||
|
onChange={(e) => setAddress(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-slate-400 font-bold mb-1">Notas / Políticas de Garantía & Horarios</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Ej. Especialista en Nike/Jordan Top Quality. Recogida de 9:00 AM a 6:00 PM."
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-3.5 py-2.5 text-white outline-hidden focus:border-emerald-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 p-3 rounded-2xl border border-slate-800 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-white text-xs">Estado del Proveedor</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
{isActive
|
||||||
|
? '🟢 Activo: disponible para asignar nuevas zapatillas y recibir pedidos'
|
||||||
|
: '⚪ Desactivado: no aparecerá en nuevas publicaciones (el historial se mantiene)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isActive}
|
||||||
|
onChange={(e) => setIsActive(e.target.checked)}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-hidden rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 pt-2 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="bg-slate-800 hover:bg-slate-700 text-slate-300 font-bold py-2.5 px-4 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-400 hover:to-teal-500 text-white font-bold py-2.5 px-5 rounded-xl shadow-lg cursor-pointer transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Guardando...' : editingSupplier ? 'Actualizar Proveedor' : 'Guardar Proveedor'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,3 +28,49 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateOrder(targetId, payload);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Orden no encontrada' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, order: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de la orden requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = dataService.deleteOrder(id);
|
||||||
|
return NextResponse.json({ success: deleted, message: deleted ? 'Orden eliminada correctamente' : 'No se encontró la orden' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
71
zapply-app/src/app/api/suppliers/route.ts
Normal file
71
zapply-app/src/app/api/suppliers/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { dataService } from '@/lib/data-service';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeInactive = searchParams.get('all') === 'true' || searchParams.get('includeInactive') === 'true';
|
||||||
|
const suppliers = dataService.getSuppliers(includeInactive);
|
||||||
|
return NextResponse.json({ success: true, suppliers });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
if (!body.name || !body.phone) {
|
||||||
|
return NextResponse.json({ success: false, error: 'El nombre y el teléfono son requeridos' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const newSupplier = dataService.addSupplier(body);
|
||||||
|
return NextResponse.json({ success: true, supplier: newSupplier });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { id, updates, ...rest } = body;
|
||||||
|
const targetId = id || rest.id;
|
||||||
|
if (!targetId) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = updates || rest;
|
||||||
|
const updated = dataService.updateSupplier(targetId, payload);
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json({ success: false, error: 'Proveedor no encontrado' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, supplier: updated });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
let id = searchParams.get('id');
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
id = body?.id;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: 'ID de proveedor requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-deactivate to ensure 100% preservation of historical orders & catalog references
|
||||||
|
const success = dataService.deleteSupplier(id);
|
||||||
|
return NextResponse.json({ success, message: 'Proveedor desactivado correctamente sin perder historial' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user