feat(ADM-018): completed feature
This commit is contained in:
178
project/apps/admin/src/app/(auth)/login/page.tsx
Normal file
178
project/apps/admin/src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Redirect if already logged in
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.id) {
|
||||
router.push('/');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
data?.message ||
|
||||
(data?.code === 'TOO_MANY_ATTEMPTS'
|
||||
? 'Demasiados intentos. Espera un momento.'
|
||||
: 'Email o contraseña incorrectos'),
|
||||
);
|
||||
} else {
|
||||
router.push('/');
|
||||
}
|
||||
} catch {
|
||||
setError('Error de conexión. Intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ minHeight: '100vh' }}
|
||||
className="flex items-center justify-center bg-gray-50 px-4"
|
||||
>
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 mb-2">
|
||||
<svg
|
||||
className="w-10 h-10 text-[#2D6A4F]"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
>
|
||||
<circle cx="16" cy="16" r="14" stroke="currentColor" strokeWidth="2" />
|
||||
<path
|
||||
d="M10 20c2-4 4-8 6-10s4 6 6 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="16" cy="10" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1
|
||||
className="text-2xl font-bold text-gray-900"
|
||||
style={{ fontFamily: 'var(--font-heading)' }}
|
||||
>
|
||||
MercadoDeVida
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Panel de administración</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 shadow-sm">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6 text-center">
|
||||
Iniciar sesión
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg px-4 py-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@mercadodevida.es"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none transition-all"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-60 text-white font-semibold rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Entrando...
|
||||
</>
|
||||
) : (
|
||||
'Iniciar sesión'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-gray-400 mt-6">
|
||||
© {new Date().getFullYear()} MercadoDeVida
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
project/apps/admin/src/app/(dashboard)/audit/page.tsx
Normal file
129
project/apps/admin/src/app/(dashboard)/audit/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { auditApi, type AuditEntry } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
|
||||
'admin.mfa.status': 'bg-purple-100 text-purple-700',
|
||||
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
|
||||
'auth.login': 'bg-blue-100 text-blue-700',
|
||||
'auth.logout': 'bg-gray-100 text-gray-600',
|
||||
'product.created': 'bg-green-100 text-green-700',
|
||||
'product.updated': 'bg-green-100 text-green-700',
|
||||
'product.deleted': 'bg-red-100 text-red-700',
|
||||
'order.placed': 'bg-indigo-100 text-indigo-700',
|
||||
'order.state_changed': 'bg-indigo-100 text-indigo-700',
|
||||
};
|
||||
|
||||
function colorForAction(action: string): string {
|
||||
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [items, setItems] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(filter), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
|
||||
setItems(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs">
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="Filtrar por acción..." value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map(entry => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||||
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
|
||||
{entry.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
|
||||
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal file
244
project/apps/admin/src/app/(dashboard)/brands/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
'use client';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Brand } from '@/types';
|
||||
import { brandsApi } from '@/lib/api-client';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function autoSeoTitle(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function autoSeoDescription(name: string): string {
|
||||
return `${name} — Compra online en MercadoDeVida. Productos naturales y ecológicos con envío a toda España.`;
|
||||
}
|
||||
|
||||
export default function BrandsPage() {
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Brand | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDescription, setSeoDescription] = useState('');
|
||||
const [seoDescriptionManual, setSeoDescriptionManual] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await brandsApi.list();
|
||||
setBrands((data as { items?: Brand[] }).items ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescriptionManual(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (b: Brand) => {
|
||||
setEditing(b);
|
||||
setName(b.name);
|
||||
setSlug(b.slug); setSlugManual(true);
|
||||
setSeoTitle(b.seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDescription(b.seoDescription ?? ''); setSeoDescriptionManual(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugManual) setSlug(slugify(value));
|
||||
if (!seoTitleManual) setSeoTitle(autoSeoTitle(value));
|
||||
if (!seoDescriptionManual) setSeoDescription(autoSeoDescription(value));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
seoTitle: seoTitle || undefined,
|
||||
seoDescription: seoDescription || undefined,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await brandsApi.update(editing.id, payload);
|
||||
setMsg('Marca actualizada');
|
||||
} else {
|
||||
await brandsApi.create(payload);
|
||||
setMsg('Marca creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta marca?')) return;
|
||||
try {
|
||||
await brandsApi.delete!(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Marcas</h1>
|
||||
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva marca</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar marca' : 'Nueva marca'}</h2>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ej: NaturGreen"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Slug *</label>
|
||||
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
placeholder="auto-generado"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Title */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">SEO Title</label>
|
||||
<span className="text-xs text-gray-400">{seoTitleManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={seoTitle}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
placeholder="auto-generado desde nombre"
|
||||
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs ${seoTitle.length > 60 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
|
||||
{seoTitle.length}/60
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">SEO Description</label>
|
||||
<span className="text-xs text-gray-400">{seoDescriptionManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
onChange={(e) => { setSeoDescriptionManual(true); setSeoDescription(e.target.value); }}
|
||||
rows={2}
|
||||
placeholder="auto-generado desde nombre"
|
||||
className="w-full px-4 py-2.5 pr-14 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<span className={`absolute right-3 bottom-2 text-xs ${seoDescription.length > 160 ? 'text-red-500 font-medium' : 'text-gray-400'}`}>
|
||||
{seoDescription.length}/160
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
brands.length === 0 ? <div className="p-12 text-center text-gray-400">No hay marcas</div> :
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Nombre', 'Slug', 'SEO Title'].map(h => (
|
||||
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
||||
))}
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{brands.map(b => (
|
||||
<tr key={b.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{b.name}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500 font-mono">/{b.slug}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{b.seoTitle ?? '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => openEdit(b)} className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
<button onClick={() => handleDelete(b.id)} className="text-xs text-red-600 hover:underline">Eliminar</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
project/apps/admin/src/app/(dashboard)/categories/page.tsx
Normal file
272
project/apps/admin/src/app/(dashboard)/categories/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Category } from '@/types';
|
||||
import { categoriesApi } from '@/lib/api-client';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function CategoryRow({ cat, onEdit, onDelete }: { cat: Category; onEdit: (c: Category) => void; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.children && cat.children.length > 0 && <span className="text-gray-300">📁</span>}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{cat.name}</p>
|
||||
<p className="text-xs text-gray-400">/{cat.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{cat.parentId ? 'Sí' : 'Raíz'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onEdit(cat)} className="p-1.5 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => onDelete(cat.id)} className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [tree, setTree] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editing, setEditing] = useState<Category | null>(null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [slugManual, setSlugManual] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [seoTitle, setSeoTitle] = useState('');
|
||||
const [seoTitleManual, setSeoTitleManual] = useState(false);
|
||||
const [seoDescription, setSeoDescription] = useState('');
|
||||
const [seoDescManual, setSeoDescManual] = useState(false);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await categoriesApi.list() as { items?: Category[] };
|
||||
setTree(data?.items ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setName(''); setSlug(''); setSlugManual(false);
|
||||
setDescription('');
|
||||
setSeoTitle(''); setSeoTitleManual(false);
|
||||
setSeoDescription(''); setSeoDescManual(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (cat: Category) => {
|
||||
setEditing(cat);
|
||||
setName(cat.name); setSlug(cat.slug); setSlugManual(true);
|
||||
setDescription(cat.description ?? '');
|
||||
setSeoTitle((cat as any).seoTitle ?? ''); setSeoTitleManual(true);
|
||||
setSeoDescription((cat as any).seoDescription ?? ''); setSeoDescManual(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (!slugManual) setSlug(slugify(value));
|
||||
if (!seoTitleManual) setSeoTitle(value);
|
||||
if (!seoDescManual) setSeoDescription(`${value} — Compra online en MercadoDeVida. Productos naturales y ecológicos.`);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setMsg('');
|
||||
try {
|
||||
if (editing) {
|
||||
await categoriesApi.update(editing.id, { name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
setMsg('Categoría actualizada');
|
||||
} else {
|
||||
await categoriesApi.create({ name, slug, description: description || undefined, seoTitle: seoTitle || undefined, seoDescription: seoDescription || undefined });
|
||||
setMsg('Categoría creada');
|
||||
}
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta categoría?')) return;
|
||||
try {
|
||||
await categoriesApi.delete(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Error al eliminar');
|
||||
}
|
||||
};
|
||||
|
||||
const flat = (cats: Category[]): Category[] =>
|
||||
cats.flatMap((c) => [c, ...flat(c.children ?? [])]);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Categorías</h1>
|
||||
<button onClick={openCreate} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">
|
||||
+ Nueva categoría
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">{editing ? 'Editar categoría' : 'Nueva categoría'}</h2>
|
||||
|
||||
{/* Nombre */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Ej: Alimentación"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Slug *</label>
|
||||
<span className="text-xs text-gray-400">{slugManual ? 'editado' : 'auto'}</span>
|
||||
</div>
|
||||
<input
|
||||
value={slug}
|
||||
onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
|
||||
placeholder="auto-generado"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm font-mono focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descripción */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Descripción</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Descripción opcional de la categoría"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Title */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Título SEO (Google)</label>
|
||||
<span className={`text-xs ${seoTitleManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoTitleManual ? 'editado' : 'copiado del nombre'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={seoTitle}
|
||||
maxLength={60}
|
||||
onChange={(e) => { setSeoTitleManual(true); setSeoTitle(e.target.value); }}
|
||||
placeholder="Título para Google (copiado del nombre)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoTitle.length}/60</div>
|
||||
</div>
|
||||
|
||||
{/* SEO Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-gray-700">Descripción SEO (Google)</label>
|
||||
<span className={`text-xs ${seoDescManual ? 'text-gray-400' : 'text-[#2D6A4F] font-medium'}`}>
|
||||
{seoDescManual ? 'editada' : 'auto-generada'}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={seoDescription}
|
||||
maxLength={160}
|
||||
onChange={(e) => { setSeoDescManual(true); setSeoDescription(e.target.value); }}
|
||||
rows={2}
|
||||
placeholder="Descripción para Google (max 160 caracteres)"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm resize-none focus:ring-2 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">{seoDescription.length}/160</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name || !slug}
|
||||
className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 text-center text-gray-400">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-red-600">{error}</div>
|
||||
) : flat(tree).length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-400">No hay categorías</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Nombre</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Subcategoría</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{flat(tree).map((c) => (
|
||||
<CategoryRow key={c.id} cat={c} onEdit={openEdit} onDelete={handleDelete} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
project/apps/admin/src/app/(dashboard)/cms/page.tsx
Normal file
97
project/apps/admin/src/app/(dashboard)/cms/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { cmsApi } from '@/lib/api-client';
|
||||
|
||||
interface Page { id: string; slug: string; title: string; body: string; status: string; createdAt: string; updatedAt: string; }
|
||||
|
||||
export default function CmsPage() {
|
||||
const [items, setItems] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState({ slug: '', title: '', body: '' });
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const d = await cmsApi.list() as { items: Page[] }; setItems(d.items ?? []); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
try {
|
||||
await cmsApi.create({ slug: form.slug, title: form.title, body: form.body });
|
||||
setMsg('Página creada');
|
||||
setShowForm(false);
|
||||
setForm({ slug: '', title: '', body: '' });
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error al crear'); }
|
||||
};
|
||||
|
||||
const togglePublish = async (id: string, currentStatus: string) => {
|
||||
try {
|
||||
if (currentStatus === 'published') await cmsApi.unpublish(id);
|
||||
else await cmsApi.publish(id);
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Páginas CMS</h1>
|
||||
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva página</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Nueva página</h2>
|
||||
{[['slug','Slug *','text'],['title','Título *','text']].map(([k,label,t]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<input value={(form as Record<string,string>)[k]} onChange={e => setForm({...form,[k]:e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contenido *</label>
|
||||
<textarea value={form.body} onChange={e => setForm({...form,body:e.target.value})} rows={6} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none font-mono" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} disabled={!form.slug || !form.title || !form.body} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl">Crear</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay páginas</div> :
|
||||
items.map(p => (
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm">{p.title}</p>
|
||||
<p className="text-xs text-gray-400 font-mono">/{p.slug}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{p.status === 'published' ? 'Publicada' : 'Borrador'}
|
||||
</span>
|
||||
<button onClick={() => togglePublish(p.id, p.status)} className="text-xs text-[#2D6A4F] hover:underline">
|
||||
{p.status === 'published' ? 'Despublicar' : 'Publicar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
import type { Customer } from '@/types';
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.get(id).then(setCustomer).catch(() => setError('No se encontró el cliente')).finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!customer) return;
|
||||
setSaving(true);
|
||||
setMsg('');
|
||||
try {
|
||||
// PATCH /users/:id for profile fields (displayName, phone — role changes require separate process)
|
||||
await fetch(`/api/customers/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: customer.role }),
|
||||
});
|
||||
setMsg('Cliente actualizado');
|
||||
} catch {
|
||||
setMsg('Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-400">Cargando...</div>;
|
||||
if (error || !customer) return <div className="p-8 text-red-600">{error || 'No encontrado'}</div>;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl space-y-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button onClick={() => router.push('/customers')} className="text-sm text-gray-500 hover:text-gray-700">← Clientes</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{customer.email}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Email</p>
|
||||
<p className="text-sm text-gray-900">{customer.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Rol</p>
|
||||
<select
|
||||
value={customer.role}
|
||||
onChange={(e) => setCustomer({ ...customer, role: e.target.value as 'customer' | 'admin' })}
|
||||
className="px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none bg-white"
|
||||
>
|
||||
<option value="customer">Cliente</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Registrado</p>
|
||||
<p className="text-sm text-gray-900">
|
||||
{customer.createdAt ? new Date(customer.createdAt).toLocaleString('es-ES') : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
project/apps/admin/src/app/(dashboard)/customers/page.tsx
Normal file
298
project/apps/admin/src/app/(dashboard)/customers/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Customer } from '@/types';
|
||||
import { customersApi } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// ── Modal genérico ────────────────────────────────────────────────────────────
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formulario crear cliente ─────────────────────────────────────────────────
|
||||
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await customersApi.create({ email, password, displayName: displayName || undefined, phone: phone || undefined });
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al crear cliente');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Opcional"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Creando...' : 'Crear cliente'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Formulario editar cliente ────────────────────────────────────────────────
|
||||
function EditForm({ customer, onClose, onSaved }: { customer: Customer; onClose: () => void; onSaved: () => void }) {
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(customer.displayName || '');
|
||||
setPhone(customer.phone || '');
|
||||
}, [customer]);
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
await customersApi.update(customer.id, {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={customer.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nombre / Razón social</label>
|
||||
<input type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Teléfono</label>
|
||||
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{error}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Página principal ───────────────────────────────────────────────────────────
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<Customer | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await customersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
|
||||
setCustomers(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreated = () => { setMsg('Cliente creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
const handleSaved = () => { setMsg('Cliente actualizado'); setTimeout(() => setMsg(''), 3000); load(); };
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clientes</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} cliente${total !== 1 ? 's' : ''}` : ''}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
+ Nuevo cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<div className="p-4 rounded-xl text-sm bg-green-50 text-green-700">{msg}</div>
|
||||
)}
|
||||
|
||||
{/* Buscador */}
|
||||
<div className="relative max-w-sm">
|
||||
<input type="search" placeholder="Buscar por email..." value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none" />
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Tabla */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
|
||||
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
<span className="text-sm">Cargando...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
|
||||
</div>
|
||||
) : customers.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">👥</p>
|
||||
<p className="text-gray-500 text-sm">No hay clientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Email', 'Nombre', 'Teléfono', 'Rol', 'Alta', ''].map((h) => (
|
||||
<th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{customers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{c.email}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{c.displayName || '—'}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{c.phone || '—'}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium capitalize ${
|
||||
c.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{c.role}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">
|
||||
{c.createdAt ? new Date(c.createdAt).toLocaleDateString('es-ES') : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button onClick={() => setEditing(c)}
|
||||
className="text-xs text-[#2D6A4F] hover:underline">Editar</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Paginación */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-gray-500">Página {page + 1}</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
|
||||
← Anterior
|
||||
</button>
|
||||
<button onClick={() => setPage((p) => p + 1)} disabled={customers.length < PAGE_SIZE}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white">
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal crear */}
|
||||
{showCreate && (
|
||||
<Modal title="Nuevo cliente" onClose={() => setShowCreate(false)}>
|
||||
<CreateForm onClose={() => setShowCreate(false)} onCreated={handleCreated} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Modal editar */}
|
||||
{editing && (
|
||||
<Modal title={`Editar: ${editing.email}`} onClose={() => setEditing(null)}>
|
||||
<EditForm customer={editing} onClose={() => setEditing(null)} onSaved={handleSaved} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
project/apps/admin/src/app/(dashboard)/inventory/page.tsx
Normal file
348
project/apps/admin/src/app/(dashboard)/inventory/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productsApi, inventoryApi } from '@/lib/api-client';
|
||||
import type { Product, ProductVariant, StockAvailability } from '@/types';
|
||||
|
||||
interface VariantRow {
|
||||
productId: string;
|
||||
productName: string;
|
||||
variant: ProductVariant;
|
||||
stock: StockAvailability | null;
|
||||
loading: boolean;
|
||||
editing: boolean;
|
||||
editValue: string;
|
||||
saving: boolean;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
type StockFilter = 'all' | 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||
|
||||
const STOCK_LABELS: Record<StockFilter, string> = {
|
||||
all: 'Todos',
|
||||
in_stock: 'En stock',
|
||||
low_stock: 'Stock bajo',
|
||||
out_of_stock: 'Sin stock',
|
||||
};
|
||||
|
||||
function StockBadge({ qty }: { qty: number }) {
|
||||
if (qty === 0) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">Sin stock</span>;
|
||||
if (qty < 5) return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700">Stock bajo ({qty})</span>;
|
||||
return <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">En stock ({qty})</span>;
|
||||
}
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [rows, setRows] = useState<VariantRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<StockFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// Load products + variants + stock
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { items: products } = await productsApi.list({
|
||||
limit: 100,
|
||||
q: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
const variantRows: VariantRow[] = [];
|
||||
|
||||
for (const product of products ?? []) {
|
||||
const { items: variants } = await productsApi.getVariants(product.id);
|
||||
for (const variant of variants ?? []) {
|
||||
variantRows.push({
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
variant,
|
||||
stock: null,
|
||||
loading: true,
|
||||
editing: false,
|
||||
editValue: '',
|
||||
saving: false,
|
||||
msg: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setRows(variantRows);
|
||||
|
||||
// Load stock for each variant
|
||||
for (const vr of variantRows) {
|
||||
inventoryApi.getAvailability(vr.variant.id)
|
||||
.then((stock) => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === vr.variant.id
|
||||
? { ...r, stock, loading: false, editValue: String(stock.availableQuantity) }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === vr.variant.id ? { ...r, loading: false, editValue: '0' } : r,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al cargar inventario');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Filter rows
|
||||
const filtered = rows.filter((r) => {
|
||||
if (filter === 'in_stock') return (r.stock?.availableQuantity ?? 0) >= 5;
|
||||
if (filter === 'low_stock') return (r.stock?.availableQuantity ?? 0) > 0 && (r.stock?.availableQuantity ?? 0) < 5;
|
||||
if (filter === 'out_of_stock') return (r.stock?.availableQuantity ?? 0) === 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
const inStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) >= 5).length;
|
||||
const lowStockCount = rows.filter((r) => {
|
||||
const q = r.stock?.availableQuantity ?? 0;
|
||||
return q > 0 && q < 5;
|
||||
}).length;
|
||||
const outOfStockCount = rows.filter((r) => (r.stock?.availableQuantity ?? 0) === 0).length;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Inventario</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{rows.length} variantes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'En stock', count: inStockCount, cls: 'bg-green-50 border-green-100 text-green-700' },
|
||||
{ label: 'Stock bajo', count: lowStockCount, cls: 'bg-amber-50 border-amber-100 text-amber-700' },
|
||||
{ label: 'Sin stock', count: outOfStockCount, cls: 'bg-red-50 border-red-100 text-red-700' },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className={`p-4 rounded-xl border ${cls}`}>
|
||||
<p className="text-2xl font-bold">{count}</p>
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por producto o SKU..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{(Object.keys(STOCK_LABELS) as StockFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
filter === f
|
||||
? 'bg-[#2D6A4F] text-white'
|
||||
: 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{STOCK_LABELS[f]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={load}
|
||||
className="text-sm text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
Recargar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center gap-3 text-gray-400">
|
||||
<div className="h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full animate-spin" />
|
||||
<span className="text-sm">Cargando inventario...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">Reintentar</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">No hay variantes para este filtro</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200 text-left">
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Producto</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">SKU</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">EAN</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Stock</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Estado</th>
|
||||
<th className="px-4 py-3 font-semibold text-gray-600 text-xs uppercase tracking-wide">Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{filtered.map((row) => (
|
||||
<tr key={row.variant.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900">{row.productName}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600">{row.variant.sku}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-400">{row.variant.ean ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.loading ? (
|
||||
<span className="text-gray-300">—</span>
|
||||
) : row.editing ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={row.editValue}
|
||||
onChange={(e) =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? { ...r, editValue: e.target.value }
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded-lg text-sm focus:ring-1 focus:ring-[#2D6A4F] outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const qty = parseInt(row.editValue, 10);
|
||||
if (isNaN(qty) || qty < 0) return;
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, saving: true } : r,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const result = await inventoryApi.setStock(row.variant.id, qty);
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? {
|
||||
...r,
|
||||
stock: { available: result.available > 0, availableQuantity: result.available },
|
||||
editing: false,
|
||||
saving: false,
|
||||
msg: '✓',
|
||||
}
|
||||
: r,
|
||||
),
|
||||
);
|
||||
setTimeout(() => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, msg: '' } : r,
|
||||
),
|
||||
);
|
||||
}, 3000);
|
||||
} catch {
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? { ...r, saving: false, msg: 'Error' }
|
||||
: r,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={row.saving}
|
||||
className="px-2 py-1 bg-[#2D6A4F] text-white text-xs rounded-lg hover:bg-[#1B4332] disabled:opacity-50"
|
||||
>
|
||||
{row.saving ? '...' : 'OK'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id
|
||||
? {
|
||||
...r,
|
||||
editing: false,
|
||||
editValue: String(r.stock?.availableQuantity ?? 0),
|
||||
}
|
||||
: r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-gray-900">
|
||||
{row.stock?.availableQuantity ?? '—'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.variant.id === row.variant.id ? { ...r, editing: true } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="ml-1 text-gray-400 hover:text-[#2D6A4F] text-xs"
|
||||
title="Editar stock"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StockBadge qty={row.stock?.availableQuantity ?? 0} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.msg && (
|
||||
<span className={`text-xs ${row.msg === '✓' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{row.msg}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
project/apps/admin/src/app/(dashboard)/layout.tsx
Normal file
122
project/apps/admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
||||
import { visibleNavItems, type NavItem } from '@/lib/permissions';
|
||||
import type { Role } from '@/types';
|
||||
|
||||
function Sidebar({
|
||||
navItems,
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
navItems: NavItem[];
|
||||
user: { email: string; role: Role };
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-5 border-b border-gray-100">
|
||||
<img
|
||||
src="/images/logo-main.png"
|
||||
alt="MercadoDeVida"
|
||||
className="h-9 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const active =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`
|
||||
flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all
|
||||
${
|
||||
active
|
||||
? 'bg-[#2D6A4F]/10 text-[#2D6A4F] border-l-[3px] border-[#2D6A4F]'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="text-base">{item.icon}</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.badge != null && item.badge > 0 && (
|
||||
<span className="ml-auto bg-[#E76F51] text-white text-xs font-bold rounded-full px-1.5 py-0.5 min-w-[18px] text-center">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="px-3 py-4 border-t border-gray-100">
|
||||
<div className="px-3 py-2 mb-2">
|
||||
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-500">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const navItems = visibleNavItems(user.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
<Sidebar navItems={navItems} user={user} onLogout={logout} />
|
||||
<main className="flex-1 min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
273
project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
273
project/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Order, OrderState } from '@/types';
|
||||
import { ordersApi } from '@/lib/api-client';
|
||||
|
||||
const STATE_LABELS: Record<OrderState, string> = {
|
||||
PENDING: 'Pendiente',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagado',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviado',
|
||||
DELIVERED: 'Entregado',
|
||||
CANCELLED: 'Cancelado',
|
||||
REFUNDED: 'Reembolsado',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<OrderState, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-800',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
PROCESSING: 'bg-indigo-100 text-indigo-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
DELIVERED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-red-100 text-red-800',
|
||||
REFUNDED: 'bg-purple-100 text-purple-800',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
|
||||
};
|
||||
|
||||
const ALLOWED_TRANSITIONS: Record<OrderState, OrderState[]> = {
|
||||
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
|
||||
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
|
||||
PAID: ['PROCESSING', 'CANCELLED', 'REFUNDED'],
|
||||
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
|
||||
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
|
||||
DELIVERED: ['PARTIALLY_REFUNDED'],
|
||||
CANCELLED: [],
|
||||
REFUNDED: [],
|
||||
PARTIALLY_REFUNDED: [],
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<OrderState, string> = {
|
||||
AWAITING_PAYMENT: 'Marcar como Pagado',
|
||||
PAID: 'Procesar pedido',
|
||||
PROCESSING: 'Marcar como Enviado',
|
||||
SHIPPED: 'Marcar como Entregado',
|
||||
DELIVERED: 'Reembolso parcial',
|
||||
CANCELLED: 'Cancelar pedido',
|
||||
PENDING: 'Marcar como Pagado',
|
||||
REFUNDED: 'Reembolsar',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [transitioning, setTransitioning] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState<OrderState | null>(null);
|
||||
const [confirmReason, setConfirmReason] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ordersApi.get(id);
|
||||
setOrder(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleTransition = async (nextState: OrderState) => {
|
||||
setTransitioning(true);
|
||||
try {
|
||||
const updated = await ordersApi.transition(id, nextState);
|
||||
setOrder(updated);
|
||||
setShowConfirm(null);
|
||||
setConfirmReason('');
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Error al cambiar estado');
|
||||
} finally {
|
||||
setTransitioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 flex items-center justify-center min-h-64">
|
||||
<div className="text-gray-400">Cargando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !order) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<p className="text-red-600">{error || 'Pedido no encontrado'}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline mt-2">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentState = order.state as OrderState;
|
||||
const allowed = ALLOWED_TRANSITIONS[currentState] ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Back */}
|
||||
<Link href="/orders" className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6">
|
||||
← Volver a pedidos
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 font-mono">#{order.id.slice(0, 8)}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date(order.createdAt).toLocaleString('es-ES', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium ${STATE_COLORS[currentState]}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current" />
|
||||
{STATE_LABELS[currentState]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Actions */}
|
||||
{allowed.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Acciones</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allowed.map((next) => (
|
||||
<button
|
||||
key={next}
|
||||
onClick={() => setShowConfirm(next)}
|
||||
disabled={transitioning}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{ACTION_LABELS[next] ?? next}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order items */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Productos</h2>
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-start py-2 border-b border-gray-50 last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{item.name}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.quantity} × {formatPrice(item.unitPriceCents)}
|
||||
{item.discountCents > 0 && ` (-${formatPrice(item.discountCents)})`}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold text-gray-900 ml-4">
|
||||
{formatPrice((item.unitPriceCents - item.discountCents) * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Totals */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Resumen</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal</span>
|
||||
<span className="font-medium">{formatPrice(order.subtotalCents)}</span>
|
||||
</div>
|
||||
{order.discountCents > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Descuento</span>
|
||||
<span className="font-medium text-green-600">-{formatPrice(order.discountCents)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">IVA</span>
|
||||
<span className="font-medium">{formatPrice(order.taxCents)}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-2 mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-gray-900">Total</span>
|
||||
<span className="text-xl font-bold text-[#2D6A4F]">{formatPrice(order.totalCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="font-bold text-gray-900 mb-4">Historial</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-[#2D6A4F] mt-1.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{STATE_LABELS[currentState]}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(order.createdAt).toLocaleString('es-ES')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full shadow-xl">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">
|
||||
Confirmar cambio de estado
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
¿{ACTION_LABELS[showConfirm] ?? showConfirm}?
|
||||
</p>
|
||||
{(showConfirm === 'CANCELLED' || showConfirm === 'REFUNDED') && (
|
||||
<textarea
|
||||
value={confirmReason}
|
||||
onChange={(e) => setConfirmReason(e.target.value)}
|
||||
placeholder="Motivo (opcional)"
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none mb-4 resize-none"
|
||||
/>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setShowConfirm(null); setConfirmReason(''); }}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTransition(showConfirm)}
|
||||
disabled={transitioning}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
{transitioning ? 'Guardando...' : 'Confirmar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
project/apps/admin/src/app/(dashboard)/orders/page.tsx
Normal file
203
project/apps/admin/src/app/(dashboard)/orders/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { Order, OrderState } from '@/types';
|
||||
import { ordersApi } from '@/lib/api-client';
|
||||
|
||||
const ORDER_STATES: OrderState[] = [
|
||||
'PENDING',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAID',
|
||||
'PROCESSING',
|
||||
'SHIPPED',
|
||||
'DELIVERED',
|
||||
'CANCELLED',
|
||||
'REFUNDED',
|
||||
'PARTIALLY_REFUNDED',
|
||||
];
|
||||
|
||||
const STATE_LABELS: Record<OrderState, string> = {
|
||||
PENDING: 'Pendiente',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagado',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviado',
|
||||
DELIVERED: 'Entregado',
|
||||
CANCELLED: 'Cancelado',
|
||||
REFUNDED: 'Reembolsado',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<OrderState, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-800',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
PROCESSING: 'bg-indigo-100 text-indigo-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
DELIVERED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-red-100 text-red-800',
|
||||
REFUNDED: 'bg-purple-100 text-purple-800',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-800',
|
||||
};
|
||||
|
||||
function formatPrice(cents: number) {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return 'Hoy';
|
||||
if (diffDays === 1) return 'Ayer';
|
||||
if (diffDays < 30) return `Hace ${diffDays} días`;
|
||||
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterState, setFilterState] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ordersApi.list({
|
||||
status: filterState || undefined,
|
||||
q: debouncedSearch || undefined,
|
||||
limit: 20,
|
||||
});
|
||||
setOrders(data.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterState, debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pedidos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{orders?.length ?? 0} pedidos</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-6 flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por ID o email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
<select
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value)}
|
||||
className="px-3 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none bg-white"
|
||||
>
|
||||
<option value="">Todos los estados</option>
|
||||
{ORDER_STATES.map((s) => (
|
||||
<option key={s} value={s}>{STATE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
|
||||
<p className="mt-2 text-sm">Cargando...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button onClick={load} className="text-sm text-[#2D6A4F] hover:underline">
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : !orders || orders.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">🧾</p>
|
||||
<p className="text-gray-500 text-sm">No hay pedidos</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['ID', 'Fecha', 'Total', 'Estado'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{orders.map((o) => (
|
||||
<tr key={o.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3.5">
|
||||
<Link
|
||||
href={`/orders/${o.id}`}
|
||||
className="text-sm font-mono text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
{o.id.slice(0, 8)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600">{timeAgo(o.createdAt)}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(o.createdAt).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-bold text-gray-900">{formatPrice(o.totalCents)}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${STATE_COLORS[o.state]}`}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-current" />
|
||||
{STATE_LABELS[o.state]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal file
248
project/apps/admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
interface Stats {
|
||||
ordersToday: number;
|
||||
revenueTodayCents: number;
|
||||
revenueTodayFormatted: string;
|
||||
ordersByState: Record<string, number>;
|
||||
outOfStockVariants: number;
|
||||
totalActiveProducts: number;
|
||||
newCustomersThisMonth: number;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const STATE_LABELS: Record<string, string> = {
|
||||
PENDING: 'Pendientes',
|
||||
AWAITING_PAYMENT: 'Esperando pago',
|
||||
PAID: 'Pagados',
|
||||
PROCESSING: 'Procesando',
|
||||
SHIPPED: 'Enviados',
|
||||
DELIVERED: 'Entregados',
|
||||
CANCELLED: 'Cancelados',
|
||||
REFUNDED: 'Reembolsados',
|
||||
PARTIALLY_REFUNDED: 'Reembolso parcial',
|
||||
};
|
||||
|
||||
const STATE_COLORS: Record<string, string> = {
|
||||
PENDING: 'bg-amber-100 text-amber-700',
|
||||
AWAITING_PAYMENT: 'bg-orange-100 text-orange-700',
|
||||
PAID: 'bg-green-100 text-green-700',
|
||||
PROCESSING: 'bg-blue-100 text-blue-700',
|
||||
SHIPPED: 'bg-indigo-100 text-indigo-700',
|
||||
DELIVERED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-gray-100 text-gray-600',
|
||||
REFUNDED: 'bg-red-100 text-red-700',
|
||||
PARTIALLY_REFUNDED: 'bg-pink-100 text-pink-700',
|
||||
};
|
||||
|
||||
function KPICard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
trend,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
icon: string;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">{label}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
<div className="text-3xl">{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderStateBar({ state, count, total }: { state: string; count: number; total: number }) {
|
||||
const pct = total > 0 ? (count / total) * 100 : 0;
|
||||
const label = STATE_LABELS[state] ?? state;
|
||||
const color = STATE_COLORS[state] ?? 'bg-gray-100 text-gray-700';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium min-w-[100px] ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#2D6A4F] rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700 min-w-[32px] text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Stats>('/admin/stats')
|
||||
.then(setStats)
|
||||
.catch(() => setError('No se pudieron cargar las estadísticas'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-3" />
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 animate-pulse">
|
||||
<div className="h-5 bg-gray-200 rounded w-1/4 mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-8 bg-gray-100 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{error ?? 'Error desconocido'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalOrders = Object.values(stats.ordersByState).reduce((a, b) => a + b, 0);
|
||||
const ordersByStateSorted = Object.entries(stats.ordersByState).sort(
|
||||
([, a], [, b]) => b - a,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Pedidos hoy"
|
||||
value={String(stats.ordersToday)}
|
||||
sub="Órdenes del día"
|
||||
icon="📦"
|
||||
/>
|
||||
<KPICard
|
||||
label="Ingresos hoy"
|
||||
value={formatCents(stats.revenueTodayCents)}
|
||||
sub="Revenue del día"
|
||||
icon="💶"
|
||||
/>
|
||||
<KPICard
|
||||
label="Productos activos"
|
||||
value={String(stats.totalActiveProducts)}
|
||||
sub="En el catálogo"
|
||||
icon="🌿"
|
||||
/>
|
||||
<KPICard
|
||||
label="Sin stock"
|
||||
value={String(stats.outOfStockVariants)}
|
||||
sub="Variantes agotadas"
|
||||
icon="⚠️"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secondary KPIs */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
label="Clientes nuevos"
|
||||
value={String(stats.newCustomersThisMonth)}
|
||||
sub="Este mes"
|
||||
icon="👥"
|
||||
/>
|
||||
<KPICard
|
||||
label="Total pedidos"
|
||||
value={String(totalOrders)}
|
||||
sub="En el sistema"
|
||||
icon="📋"
|
||||
/>
|
||||
<KPICard
|
||||
label="Alertas"
|
||||
value={
|
||||
stats.outOfStockVariants > 0
|
||||
? `${stats.outOfStockVariants} sin stock`
|
||||
: 'Sin alertas'
|
||||
}
|
||||
sub={stats.outOfStockVariants > 0 ? 'Revisar inventario' : 'Todo OK'}
|
||||
icon={stats.outOfStockVariants > 0 ? '🔴' : '✅'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Orders by state */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900">Pedidos por estado</h2>
|
||||
<span className="text-sm text-gray-500">{totalOrders} total</span>
|
||||
</div>
|
||||
|
||||
{totalOrders === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400 text-sm">
|
||||
<p className="text-3xl mb-2">📋</p>
|
||||
<p>No hay pedidos en el sistema</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{ordersByStateSorted.map(([state, count]) => (
|
||||
<OrderStateBar
|
||||
key={state}
|
||||
state={state}
|
||||
count={count}
|
||||
total={totalOrders}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h2 className="text-base font-semibold text-gray-900 mb-4">Acciones rápidas</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ href: '/products/new', label: '+ Nuevo producto', icon: '🌿' },
|
||||
{ href: '/orders', label: 'Ver pedidos', icon: '📦' },
|
||||
{ href: '/inventory', label: 'Revisar stock', icon: '📊' },
|
||||
{ href: '/customers', label: 'Clientes', icon: '👥' },
|
||||
].map(({ href, label, icon }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-2 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 hover:border-[#2D6A4F] transition-colors text-sm font-medium text-gray-700"
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
project/apps/admin/src/app/(dashboard)/payments/page.tsx
Normal file
130
project/apps/admin/src/app/(dashboard)/payments/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { paymentsApi, type PaymentTransaction } from '@/lib/api-client';
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
succeeded: 'bg-green-100 text-green-700',
|
||||
requires_payment: 'bg-yellow-100 text-yellow-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
refunded: 'bg-gray-100 text-gray-600',
|
||||
chargeback: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [items, setItems] = useState<PaymentTransaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebounced(filter), 400); return () => clearTimeout(t); }, [filter]);
|
||||
useEffect(() => { setPage(0); }, [debounced]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await paymentsApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, q: debounced || undefined });
|
||||
setItems(data.items ?? []); setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [page, debounced]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleRefund = async (id: string) => {
|
||||
if (!confirm('¿Reembolsar este pago? Esta acción no se puede deshacer.')) return;
|
||||
try {
|
||||
await paymentsApi.refund(id);
|
||||
setMsg('Reembolso procesado'); setTimeout(() => setMsg(''), 3000); load();
|
||||
} catch (er) { alert(er instanceof Error ? er.message : 'Error al reembolsar'); }
|
||||
};
|
||||
|
||||
const fmt = (cents: number) => `€${(cents / 100).toFixed(2)}`;
|
||||
const fmtDate = (d: string) => new Date(d).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' });
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Pagos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} transacción${total !== 1 ? 'es' : ''}` : ''}</p>
|
||||
</div>
|
||||
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input type="text" placeholder="Buscar por ID de pago..." value={filter} onChange={e => setFilter(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">💳</span><span>Sin transacciones</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Importe</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Provider</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">ID Pago</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{items.map(txn => (
|
||||
<tr key={txn.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">{fmtDate(txn.createdAt)}</td>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(txn.amountCents)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[txn.status] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{txn.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{txn.provider}</td>
|
||||
<td className="px-6 py-4 text-xs font-mono text-gray-400 max-w-[120px] truncate">{txn.providerPaymentId ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
{txn.status === 'succeeded' && (
|
||||
<button onClick={() => handleRefund(txn.id)}
|
||||
className="text-sm text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition-colors">
|
||||
Reembolsar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ProductEditor } from '@/features/products/components/ProductEditor';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ProductEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
return <ProductEditor productId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ProductEditor } from '@/features/products/components/ProductEditor';
|
||||
|
||||
export default function NewProductPage() {
|
||||
return <ProductEditor />;
|
||||
}
|
||||
224
project/apps/admin/src/app/(dashboard)/products/page.tsx
Normal file
224
project/apps/admin/src/app/(dashboard)/products/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import type { Product } from '@/types';
|
||||
import { productsApi } from '@/lib/api-client';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatPrice(cents?: number) {
|
||||
if (cents == null) return '—';
|
||||
return `€${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function StateBadge({ state }: { state: string }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: 'Activo', cls: 'bg-green-100 text-green-800' },
|
||||
archived: { label: 'Archivado', cls: 'bg-gray-100 text-gray-600' },
|
||||
draft: { label: 'Borrador', cls: 'bg-amber-100 text-amber-800' },
|
||||
};
|
||||
const { label, cls } = map[state] ?? { label: state, cls: 'bg-gray-100 text-gray-600' };
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const router = useRouter();
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 400);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
// Reset page on search change
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await productsApi.list({
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
q: debouncedSearch || undefined,
|
||||
});
|
||||
setProducts(data.items ?? []);
|
||||
setTotal(data.items?.length ?? 0);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE) || 1;
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Productos</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total} productos</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/products/new"
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
+ Crear producto
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-6">
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Buscar por nombre..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] focus:border-transparent outline-none"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<div className="inline-block animate-spin h-5 w-5 border-2 border-gray-300 border-t-[#2D6A4F] rounded-full" />
|
||||
<p className="mt-2 text-sm">Cargando...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 text-sm mb-3">{error}</p>
|
||||
<button
|
||||
onClick={load}
|
||||
className="text-sm text-[#2D6A4F] hover:underline"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-4xl mb-3">📦</p>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{debouncedSearch ? 'No hay productos para esta búsqueda' : 'No hay productos'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Producto
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Marca
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">
|
||||
Estado
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{products.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
onClick={() => router.push(`/products/${p.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-3">
|
||||
{p.imageUrl ? (
|
||||
<img
|
||||
src={p.imageUrl}
|
||||
alt={p.name}
|
||||
className="w-10 h-10 rounded-lg object-cover bg-gray-100 flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-lg flex-shrink-0">
|
||||
🌿
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
|
||||
{p.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 truncate max-w-xs">
|
||||
{p.description?.slice(0, 60) ?? p.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm text-gray-600">{p.brand?.name ?? '—'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StateBadge state={p.state} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-gray-300">→</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-gray-500">
|
||||
Página {page + 1} de {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
|
||||
>
|
||||
← Anterior
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg disabled:opacity-40 hover:bg-white transition-colors"
|
||||
>
|
||||
Siguiente →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
project/apps/admin/src/app/(dashboard)/promotions/page.tsx
Normal file
137
project/apps/admin/src/app/(dashboard)/promotions/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { promotionsApi } from '@/lib/api-client';
|
||||
|
||||
interface Promo { code: string; type: string; value: number; startsAt: string; endsAt: string; active: boolean; usageLimit: number | null; usageCount: number; }
|
||||
|
||||
export default function PromotionsPage() {
|
||||
const [items, setItems] = useState<Promo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const [form, setForm] = useState({
|
||||
code: '', type: 'percent', value: '',
|
||||
startsAt: new Date().toISOString().split('T')[0],
|
||||
endsAt: new Date(Date.now() + 30 * 86400000).toISOString().split('T')[0],
|
||||
usageLimit: '',
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await promotionsApi.list() as { items: Promo[] };
|
||||
setItems(d.items ?? []);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setMsg('');
|
||||
try {
|
||||
await promotionsApi.create({
|
||||
code: form.code,
|
||||
type: form.type,
|
||||
value: parseInt(form.value, 10),
|
||||
startsAt: new Date(form.startsAt).toISOString(),
|
||||
endsAt: new Date(form.endsAt).toISOString(),
|
||||
usageLimit: form.usageLimit ? parseInt(form.usageLimit, 10) : null,
|
||||
});
|
||||
setMsg('Promoción creada');
|
||||
setShowForm(false);
|
||||
load();
|
||||
} catch (e) { setMsg(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
const toggleActive = async (code: string, currentActive: boolean) => {
|
||||
try {
|
||||
await promotionsApi.update(code, { active: !currentActive });
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
const handleDelete = async (code: string) => {
|
||||
if (!confirm(`¿Eliminar "${code}"?`)) return;
|
||||
try { await promotionsApi.delete(code); load(); }
|
||||
catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Promociones</h1>
|
||||
<button onClick={() => setShowForm(true)} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">+ Nueva promoción</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-4 rounded-xl text-sm ${msg.startsWith('Error') ? 'bg-red-50 text-red-700' : 'bg-green-50 text-green-700'}`}>{msg}</div>}
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-gray-900">Nueva promoción</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[['code','Código *','text'],['value','Valor *','number']].map(([k, label, t]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<input type={t} value={(form as Record<string,string>)[k]} onChange={e => setForm({...form, [k]: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tipo</label>
|
||||
<select value={form.type} onChange={e => setForm({...form, type: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm bg-white focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="percent">Porcentaje</option><option value="fixed_amount">Cantidad fija</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Límite de uso</label>
|
||||
<input type="number" value={form.usageLimit} onChange={e => setForm({...form, usageLimit: e.target.value})} placeholder="Sin límite" className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha inicio</label>
|
||||
<input type="date" value={form.startsAt} onChange={e => setForm({...form, startsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fecha fin</label>
|
||||
<input type="date" value={form.endsAt} onChange={e => setForm({...form, endsAt: e.target.value})} className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handleSave} className="px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl">Crear</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay promociones</div> :
|
||||
<table className="w-full">
|
||||
<thead><tr className="bg-gray-50 border-b border-gray-200">
|
||||
{['Código','Tipo','Valor','Activa','Límite','Usos','Fin'].map(h => <th key={h} className="text-left text-xs font-semibold text-gray-500 uppercase tracking-wide px-4 py-3">{h}</th>)}
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{items.map(p => (
|
||||
<tr key={p.code} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3.5 font-mono text-sm font-medium text-gray-900">{p.code}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-600">{p.type === 'percent' ? '%' : 'Fijo'}</td>
|
||||
<td className="px-4 py-3.5 text-sm font-medium text-gray-900">{p.type === 'percent' ? `${p.value / 100}%` : `€${(p.value / 100).toFixed(2)}`}</td>
|
||||
<td className="px-4 py-3.5">
|
||||
<button onClick={() => toggleActive(p.code, p.active)} className={`px-2 py-0.5 rounded-full text-xs font-medium ${p.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>{p.active ? 'Sí' : 'No'}</button>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageLimit ?? '∞'}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{p.usageCount}</td>
|
||||
<td className="px-4 py-3.5 text-sm text-gray-500">{new Date(p.endsAt).toLocaleDateString('es-ES')}</td>
|
||||
<td className="px-4 py-3.5"><button onClick={() => handleDelete(p.code)} className="text-xs text-red-600 hover:underline">Eliminar</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
project/apps/admin/src/app/(dashboard)/reviews/page.tsx
Normal file
96
project/apps/admin/src/app/(dashboard)/reviews/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { reviewsApi } from '@/lib/api-client';
|
||||
|
||||
interface Review {
|
||||
id: string; productId: string; userId: string; orderId: string;
|
||||
rating: number; title: string; body: string; status: string; createdAt: string;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { pending: 'Pendiente', published: 'Publicada', rejected: 'Rechazada' };
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
published: 'bg-green-100 text-green-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
function Stars({ n }: { n: number }) {
|
||||
return (
|
||||
<span className="text-amber-400 text-sm">
|
||||
{'★'.repeat(n)}{'☆'.repeat(5 - n)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReviewsPage() {
|
||||
const [items, setItems] = useState<Review[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const d = await reviewsApi.listAdmin({ status: filter || undefined, limit: 50 }) as { items: Review[]; total: number };
|
||||
setItems(d.items ?? []);
|
||||
setTotal(d.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const moderate = async (id: string, status: 'published' | 'rejected') => {
|
||||
try {
|
||||
await reviewsApi.moderate(id, status);
|
||||
load();
|
||||
} catch (e) { alert(e instanceof Error ? e.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold text-gray-900">Reseñas</h1><p className="text-sm text-gray-500 mt-0.5">{total} reseñas pendientes de moderación</p></div>
|
||||
<div className="flex gap-2">
|
||||
{['', 'pending', 'published', 'rejected'].map(s => (
|
||||
<button key={s} onClick={() => setFilter(s)} className={`px-3 py-1.5 rounded-lg text-xs font-medium ${filter === s ? 'bg-[#2D6A4F] text-white' : 'bg-white border border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||
{s ? STATUS_LABELS[s] : 'Todas'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? <div className="p-12 text-center text-gray-400">Cargando...</div> :
|
||||
error ? <div className="p-8 text-center text-red-600">{error}</div> :
|
||||
items.length === 0 ? <div className="p-12 text-center text-gray-400">No hay reseñas</div> :
|
||||
items.map(r => (
|
||||
<div key={r.id} className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<Stars n={r.rating} />
|
||||
<p className="font-semibold text-gray-900 text-sm mt-1">{r.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{new Date(r.createdAt).toLocaleString('es-ES')}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_CLS[r.status] ?? 'bg-gray-100 text-gray-600'}`}>{STATUS_LABELS[r.status] ?? r.status}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-4">{r.body}</p>
|
||||
{r.status === 'pending' && (
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => moderate(r.id, 'published')} className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-xs font-semibold rounded-lg">✓ Publicar</button>
|
||||
<button onClick={() => moderate(r.id, 'rejected')} className="px-4 py-2 border border-red-200 text-red-600 hover:bg-red-50 text-xs font-semibold rounded-lg">✕ Rechazar</button>
|
||||
</div>
|
||||
)}
|
||||
{r.status !== 'pending' && (
|
||||
<button onClick={() => moderate(r.id, r.status === 'published' ? 'rejected' : 'published')} className="text-xs text-gray-400 hover:text-gray-600">
|
||||
{r.status === 'published' ? 'Despublicar' : 'Aprobar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
project/apps/admin/src/app/(dashboard)/settings/page.tsx
Normal file
109
project/apps/admin/src/app/(dashboard)/settings/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { settingsApi, type StoreSettings } from '@/lib/api-client';
|
||||
|
||||
type FormData = StoreSettings;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [data, setData] = useState<FormData | null>(null);
|
||||
const [form, setForm] = useState<FormData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get().then(d => {
|
||||
setData(d); setForm(d);
|
||||
}).catch(() => setErr('Error al cargar ajustes')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
setSaving(true); setErr(''); setMsg('');
|
||||
try {
|
||||
const updated = await settingsApi.update(form);
|
||||
setData(updated); setForm(updated);
|
||||
setMsg('Cambios guardados correctamente');
|
||||
setTimeout(() => setMsg(''), 4000);
|
||||
} catch (er) {
|
||||
setErr(er instanceof Error ? er.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof FormData, label: string, opts?: { type?: string; placeholder?: string; rows?: number }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
{opts?.rows ? (
|
||||
<textarea value={form?.[key] ?? ''} onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
|
||||
rows={opts.rows} placeholder={opts.placeholder}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none resize-none" />
|
||||
) : (
|
||||
<input type={opts?.type ?? 'text'} value={form?.[key] ?? ''}
|
||||
onChange={e => setForm(f => f ? { ...f, [key]: e.target.value } : f)}
|
||||
placeholder={opts?.placeholder} maxLength={key === 'contactAddress' ? 400 : 200}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ajustes de tienda</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Configuración general de la tienda visible para los clientes.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-3 rounded-xl border border-green-200">{msg}</div>}
|
||||
{err && <div className="bg-red-50 text-red-700 text-sm px-4 py-3 rounded-xl border border-red-200">{err}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 p-12 flex items-center justify-center text-gray-400 text-sm">Cargando...</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Información general</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('storeName', 'Nombre de la tienda', { placeholder: 'Mercado de Vida' })}
|
||||
{field('storeTagline', 'Eslogan', { placeholder: 'Productos naturales y ecológicos' })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Contacto</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('contactEmail', 'Email de contacto', { type: 'email', placeholder: 'info@mercadodevida.es' })}
|
||||
{field('contactPhone', 'Teléfono', { placeholder: '+34 600 000 000' })}
|
||||
{field('contactAddress', 'Dirección', { placeholder: 'Calle ejemplo, ciudad', rows: 3 })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Redes sociales</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('facebookUrl', 'Facebook', { placeholder: 'https://facebook.com/...' })}
|
||||
{field('instagramUrl', 'Instagram', { placeholder: 'https://instagram.com/...' })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-b border-gray-200">
|
||||
<h2 className="text-base font-semibold text-gray-800">Footer</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
{field('footerText', 'Texto del pie de página', { placeholder: '© 2026 Mercado de Vida...', rows: 2 })}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 bg-gray-50 border-t border-gray-200 flex justify-end">
|
||||
<button type="submit" disabled={saving || !form}
|
||||
className="px-6 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar cambios'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
project/apps/admin/src/app/(dashboard)/shipping/page.tsx
Normal file
289
project/apps/admin/src/app/(dashboard)/shipping/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { shippingApi, type ShippingZone, type ShippingMethod } from '@/lib/api-client';
|
||||
|
||||
type Tab = 'zones' | 'methods';
|
||||
|
||||
// ── Zone helpers ──────────────────────────────────────────────────────────────────
|
||||
function ZoneRow({ zone, onEdit, onDelete }: { zone: ShippingZone; onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{zone.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{zone.country}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{zone.postalCodePrefix ?? <span className="italic">Todos</span>}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${zone.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{zone.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneForm({ zone, onSave, onCancel }: { zone?: ShippingZone; onSave: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState(zone?.name ?? '');
|
||||
const [country, setCountry] = useState(zone?.country ?? '');
|
||||
const [prefix, setPrefix] = useState(zone?.postalCodePrefix ?? '');
|
||||
const [active, setActive] = useState(zone?.active ?? true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
if (zone) {
|
||||
await shippingApi.updateZone(zone.id, { name, country, postalCodePrefix: prefix || null, active });
|
||||
} else {
|
||||
await shippingApi.createZone({ name, country, postalCodePrefix: prefix || null, active });
|
||||
}
|
||||
onSave(); onCancel();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50/50 border-b border-green-100">
|
||||
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre zona"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input value={country} onChange={e => setCountry(e.target.value)} required placeholder="ES, FR..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="Ej: 28"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<button disabled={saving} onClick={handle}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={onCancel}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
|
||||
</div>
|
||||
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Method helpers ───────────────────────────────────────────────────────────────
|
||||
function MethodRow({ method, onEdit, onDelete }: { method: ShippingMethod; onEdit: () => void; onDelete: () => void }) {
|
||||
const fmt = (cents: number) => `€${(cents / 100).toFixed(2)}`;
|
||||
return (
|
||||
<tr className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{method.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{method.zoneName}</td>
|
||||
<td className="px-6 py-4 text-sm font-semibold text-gray-800">{fmt(method.baseCostCents)}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{method.freeShippingThresholdCents ? `Gratis desde ${fmt(method.freeShippingThresholdCents)}` : '—'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${method.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{method.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={onEdit} className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 mr-1">Editar</button>
|
||||
<button onClick={onDelete} className="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50">Eliminar</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function MethodForm({ zones, method, onSave, onCancel }: { zones: ShippingZone[]; method?: ShippingMethod; onSave: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState(method?.name ?? '');
|
||||
const [zoneId, setZoneId] = useState(method?.zoneId ?? zones[0]?.id ?? '');
|
||||
const [cost, setCost] = useState(method ? String(method.baseCostCents / 100) : '');
|
||||
const [threshold, setThreshold] = useState(method?.freeShippingThresholdCents ? String(method.freeShippingThresholdCents / 100) : '');
|
||||
const [active, setActive] = useState(method?.active ?? true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
const baseCostCents = Math.round(parseFloat(cost) * 100);
|
||||
const freeThreshold = threshold ? Math.round(parseFloat(threshold) * 100) : null;
|
||||
if (method) {
|
||||
await shippingApi.updateMethod(method.id, { name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
|
||||
} else {
|
||||
await shippingApi.createMethod({ zoneId, name, baseCostCents, freeShippingThresholdCents: freeThreshold, active });
|
||||
}
|
||||
onSave(); onCancel();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="bg-green-50/50 border-b border-green-100">
|
||||
<td className="px-4 py-3"><input value={name} onChange={e => setName(e.target.value)} required placeholder="Nombre método"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={zoneId} onChange={e => setZoneId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
{zones.map(z => <option key={z.id} value={z.id}>{z.name}</option>)}
|
||||
</select></td>
|
||||
<td className="px-4 py-3"><input type="number" step="0.01" value={cost} onChange={e => setCost(e.target.value)} required placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3"><input type="number" step="0.01" value={threshold} onChange={e => setThreshold(e.target.value)} placeholder="Sin gratis"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(active)} onChange={e => setActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<button disabled={saving} onClick={handle}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={onCancel}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-white">Cancelar</button>
|
||||
</div>
|
||||
{err && <p className="text-xs text-red-600 mt-1">{err}</p>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────────
|
||||
export default function ShippingPage() {
|
||||
const [tab, setTab] = useState<Tab>('zones');
|
||||
const [zones, setZones] = useState<ShippingZone[]>([]);
|
||||
const [methods, setMethods] = useState<ShippingMethod[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingZone, setEditingZone] = useState<ShippingZone | null>(null);
|
||||
const [editingMethod, setEditingMethod] = useState<ShippingMethod | null>(null);
|
||||
const [showZoneForm, setShowZoneForm] = useState(false);
|
||||
const [showMethodForm, setShowMethodForm] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const loadZones = useCallback(async () => {
|
||||
try { const d = await shippingApi.listZones(); setZones(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const loadMethods = useCallback(async () => {
|
||||
try { const d = await shippingApi.listMethods(); setMethods(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([loadZones(), loadMethods()]);
|
||||
setLoading(false);
|
||||
}, [loadZones, loadMethods]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDeleteZone = async (id: string) => {
|
||||
if (!confirm('¿Eliminar esta zona y todos sus métodos?')) return;
|
||||
try { await shippingApi.deleteZone(id); setMsg('Zona eliminada'); setTimeout(() => setMsg(''), 3000); loadZones(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
};
|
||||
|
||||
const handleDeleteMethod = async (id: string) => {
|
||||
if (!confirm('¿Eliminar este método de envío?')) return;
|
||||
try { await shippingApi.deleteMethod(id); setMsg('Método eliminado'); setTimeout(() => setMsg(''), 3000); loadMethods(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Envíos</h1>
|
||||
{msg && <span className="text-sm text-green-600 bg-green-50 px-3 py-1 rounded-full">{msg}</span>}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{([['zones', 'Zonas de envío'], ['methods', 'Métodos de envío']] as [Tab, string][]).map(([t, label]) => (
|
||||
<button key={t} onClick={() => setTab(t)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${tab === t ? 'border-[#2D6A4F] text-[#2D6A4F]' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : tab === 'zones' ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Zonas ({zones.length})</h2>
|
||||
<button onClick={() => { setShowZoneForm(true); setEditingZone(null); }}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
+ Nueva zona
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">País</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">CP prefijo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{showZoneForm && !editingZone && (
|
||||
<ZoneForm onSave={loadZones} onCancel={() => setShowZoneForm(false)} />
|
||||
)}
|
||||
{editingZone && (
|
||||
<ZoneForm zone={editingZone} onSave={() => { setEditingZone(null); loadZones(); }} onCancel={() => setEditingZone(null)} />
|
||||
)}
|
||||
{zones.length === 0 && !showZoneForm ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-400 text-sm">Sin zonas de envío</td></tr>
|
||||
) : zones.map(z => (
|
||||
<ZoneRow key={z.id} zone={z} onEdit={() => { setEditingZone(z); setShowZoneForm(false); }}
|
||||
onDelete={() => handleDeleteZone(z.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-800">Métodos ({methods.length})</h2>
|
||||
<button onClick={() => { setShowMethodForm(true); setEditingMethod(null); }}
|
||||
className="px-4 py-2 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors"
|
||||
disabled={zones.length === 0}>
|
||||
+ Nuevo método
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Zona</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Coste</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Envío gratis</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{showMethodForm && !editingMethod && (
|
||||
<MethodForm zones={zones} onSave={loadMethods} onCancel={() => setShowMethodForm(false)} />
|
||||
)}
|
||||
{editingMethod && (
|
||||
<MethodForm zones={zones} method={editingMethod} onSave={() => { setEditingMethod(null); loadMethods(); }} onCancel={() => setEditingMethod(null)} />
|
||||
)}
|
||||
{methods.length === 0 && !showMethodForm ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-gray-400 text-sm">
|
||||
{zones.length === 0 ? 'Crea primero una zona de envío' : 'Sin métodos de envío'}
|
||||
</td></tr>
|
||||
) : methods.map(m => (
|
||||
<MethodRow key={m.id} method={m} onEdit={() => { setEditingMethod(m); setShowMethodForm(false); }}
|
||||
onDelete={() => handleDeleteMethod(m.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
project/apps/admin/src/app/(dashboard)/tax-rates/page.tsx
Normal file
128
project/apps/admin/src/app/(dashboard)/tax-rates/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { taxApi, type TaxRate } from '@/lib/api-client';
|
||||
|
||||
export default function TaxRatesPage() {
|
||||
const [rates, setRates] = useState<TaxRate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editRate, setEditRate] = useState('');
|
||||
const [editActive, setEditActive] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const d = await taxApi.list(); setRates(d.items ?? []); }
|
||||
catch { /* silent */ }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const startEdit = (r: TaxRate) => {
|
||||
setEditing(r.id); setEditName(r.name); setEditRate(String(r.ratePercent)); setEditActive(r.active);
|
||||
};
|
||||
|
||||
const handleSave = async (id: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await taxApi.update(id, { name: editName, ratePercent: parseFloat(editRate), active: editActive });
|
||||
setEditing(null); setMsg('Tipo impositivo actualizado'); setTimeout(() => setMsg(''), 3000); load();
|
||||
} catch (er) { alert(er instanceof Error ? er.message : 'Error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const fmt = (r: TaxRate) => `${r.ratePercent}%`;
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Tipos impositivos (IVA)</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Configura los tipos de IVA aplicables a los productos.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b bg-gray-50">
|
||||
<h2 className="text-base font-semibold text-gray-800">IVA en España (ES)</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Nombre</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tipo</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Tasa</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Estado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{rates.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 transition-colors">
|
||||
{editing === r.id ? (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input value={editName} onChange={e => setEditName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">{r.appliesTo}</td>
|
||||
<td className="px-4 py-3">
|
||||
<input type="number" step="0.01" value={editRate} onChange={e => setEditRate(e.target.value)}
|
||||
className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={String(editActive)} onChange={e => setEditActive(e.target.value === 'true')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="true">Activo</option><option value="false">Inactivo</option></select>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button disabled={saving} onClick={() => handleSave(r.id)}
|
||||
className="px-3 py-1.5 bg-[#2D6A4F] text-white text-xs font-medium rounded-lg hover:bg-[#1B4332] disabled:opacity-50">
|
||||
{saving ? '...' : 'Guardar'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-600 text-xs rounded-lg hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">{r.name}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 capitalize">{r.appliesTo}</td>
|
||||
<td className="px-6 py-4 text-sm font-bold text-gray-800">{fmt(r)}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{r.active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button onClick={() => startEdit(r)}
|
||||
className="text-sm text-[#2D6A4F] hover:text-[#1B4332] font-medium px-3 py-1.5 rounded-lg hover:bg-green-50 transition-colors">
|
||||
Editar
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 rounded-xl border border-amber-200 px-5 py-4">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>España:</strong> IVA General 21%, IVA Reducido 10%, IVA Superreducido 4%. Los tipos se aplican a los precios sin IVA (netos) del producto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
project/apps/admin/src/app/(dashboard)/users/page.tsx
Normal file
236
project/apps/admin/src/app/(dashboard)/users/page.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminUsersApi } from '@/lib/api-client';
|
||||
|
||||
interface AdminUser { id: string; email: string; role: string; createdAt: string; }
|
||||
|
||||
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateForm({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('editor');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try { await adminUsersApi.create({ email, password, role }); onCreated(); onClose(); }
|
||||
catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Contraseña *</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required minLength={8}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="editor">Editor</option><option value="admin">Admin</option></select></div>
|
||||
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Creando...' : 'Crear usuario'}</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function EditForm({ user, onClose, onSaved }: { user: AdminUser; onClose: () => void; onSaved: () => void }) {
|
||||
const [role, setRole] = useState(user.role);
|
||||
const [password, setPassword] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const handle = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setErr('');
|
||||
try {
|
||||
const data: { role?: string; password?: string } = { role };
|
||||
if (password) data.password = password;
|
||||
await adminUsersApi.update(user.id, data); onSaved(); onClose();
|
||||
} catch (er) { setErr(er instanceof Error ? er.message : 'Error'); } finally { setSaving(false); }
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handle} className="space-y-4">
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={user.email} disabled
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm bg-gray-50 text-gray-400" /></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Rol *</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="admin">Admin</option><option value="editor">Editor</option></select></div>
|
||||
<div><label className="block text-sm font-medium text-gray-700 mb-1">Nueva contraseña</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} minLength={8} placeholder="Dejar vacío para no cambiar"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" /></div>
|
||||
{err && <p className="text-sm text-red-600 bg-red-50 rounded-xl px-4 py-2">{err}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" disabled={saving}
|
||||
className="flex-1 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] disabled:opacity-50 text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
{saving ? 'Guardando...' : 'Guardar'}</button>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-5 py-2.5 border border-gray-300 text-gray-600 text-sm rounded-xl hover:bg-gray-50">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ROLE_COLORS: Record<string, string> = { admin: 'bg-purple-100 text-purple-700', editor: 'bg-amber-100 text-amber-700', customer: 'bg-blue-100 text-blue-700' };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [filterRole, setFilterRole] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => { const t = setTimeout(() => setDebounced(search), 400); return () => clearTimeout(t); }, [search]);
|
||||
useEffect(() => { setPage(0); }, [debounced, filterRole]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await adminUsersApi.list({ limit: PAGE_SIZE, offset: page * PAGE_SIZE, role: filterRole || undefined, q: debounced || undefined });
|
||||
setUsers(data.items ?? []); setTotal(data.total ?? 0);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); } finally { setLoading(false); }
|
||||
}, [page, debounced, filterRole]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('¿Eliminar este usuario? No se puede deshacer.')) return;
|
||||
try { await adminUsersApi.delete(id); setMsg('Usuario eliminado'); setTimeout(() => setMsg(''), 3000); load(); }
|
||||
catch (er) { alert(er instanceof Error ? er.message : 'Error al eliminar'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-2xl font-bold text-gray-900">Usuarios backoffice</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{total > 0 ? `${total} usuario${total !== 1 ? 's' : ''}` : ''}</p></div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-[#2D6A4F] hover:bg-[#1B4332] text-white text-sm font-semibold rounded-xl transition-colors">
|
||||
+ Nuevo usuario</button>
|
||||
</div>
|
||||
|
||||
{msg && <div className="bg-green-50 text-green-700 text-sm px-4 py-2.5 rounded-xl border border-green-200">{msg}</div>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<input type="text" placeholder="Buscar por email..." value={search} onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
|
||||
</div>
|
||||
<select value={filterRole} onChange={e => setFilterRole(e.target.value)}
|
||||
className="px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none">
|
||||
<option value="">Todos los roles</option>
|
||||
<option value="admin">Admin</option><option value="editor">Editor</option><option value="customer">Customer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
|
||||
<span className="text-3xl">🔐</span><span>No hay usuarios backoffice</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Email</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Rol</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Creado</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm text-gray-900">{u.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[u.role] ?? 'bg-gray-100 text-gray-600'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{new Date(u.createdAt).toLocaleDateString('es-ES')}</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setEditing(u)}
|
||||
className="p-2 text-gray-400 hover:text-[#2D6A4F] hover:bg-green-50 rounded-lg transition-colors" title="Editar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={() => handleDelete(u.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Eliminar">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">
|
||||
Mostrando {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
|
||||
Anterior
|
||||
</button>
|
||||
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<Modal title="Nuevo usuario backoffice" onClose={() => setShowCreate(false)}>
|
||||
<CreateForm onClose={() => setShowCreate(false)} onCreated={() => { setMsg('Usuario creado correctamente'); setTimeout(() => setMsg(''), 3000); load(); }} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<Modal title="Editar usuario" onClose={() => setEditing(null)}>
|
||||
<EditForm user={editing} onClose={() => setEditing(null)} onSaved={() => { setMsg('Usuario actualizado'); setTimeout(() => setMsg(''), 3000); load(); }} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
project/apps/admin/src/app/api/[...path]/route.ts
Normal file
99
project/apps/admin/src/app/api/[...path]/route.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Catch-all proxy: forwards ALL requests to the backend API.
|
||||
* This avoids CORS preflight issues since requests stay within the
|
||||
* same origin (localhost:3004 -> localhost:3004 proxy -> 127.0.0.1:3000 backend).
|
||||
*
|
||||
* More specific routes (e.g. /api/auth/login) take precedence in Next.js,
|
||||
* so they are NOT served by this handler.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const setCookie = backendRes.headers.get('set-cookie');
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
const resp = NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
if (setCookie) {
|
||||
resp.headers.set(
|
||||
'Set-Cookie',
|
||||
setCookie.replace(/;\s*Secure/gi, '').replace(/;\s*SameSite=Lax/gi, '').trim(),
|
||||
);
|
||||
}
|
||||
return resp;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
const body = await req.text();
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookies },
|
||||
body,
|
||||
});
|
||||
const data = await backendRes.json().catch(() => null);
|
||||
return NextResponse.json(data ?? { error: 'Bad response' }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const path = req.nextUrl.pathname.replace('/api/', '');
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
return NextResponse.json({ ok: backendRes.ok }, { status: backendRes.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Proxy error' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
46
project/apps/admin/src/app/api/auth/login/route.ts
Normal file
46
project/apps/admin/src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
/**
|
||||
* Strip the Secure flag from the backend's Set-Cookie so the browser
|
||||
* (which connects over HTTP) actually stores the session cookie.
|
||||
* Also drop SameSite=Lax to avoid browser restrictions.
|
||||
*/
|
||||
function makeLocalhostCompatible(cookie: string): string {
|
||||
return cookie
|
||||
.replace(/;\s*Secure/gi, '')
|
||||
.replace(/;\s*SameSite=Lax/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const backendRes = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await backendRes.json();
|
||||
|
||||
if (!backendRes.ok) {
|
||||
return NextResponse.json(data, { status: backendRes.status });
|
||||
}
|
||||
|
||||
const setCookie = backendRes.headers.get('set-cookie');
|
||||
const response = NextResponse.json(data, { status: 200 });
|
||||
if (setCookie) {
|
||||
response.headers.set('Set-Cookie', makeLocalhostCompatible(setCookie));
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ statusCode: 500, code: 'SERVER_ERROR', message: 'Error del servidor' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
19
project/apps/admin/src/app/api/auth/logout/route.ts
Normal file
19
project/apps/admin/src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
await fetch(`${API}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete('mdv_session');
|
||||
return response;
|
||||
}
|
||||
16
project/apps/admin/src/app/api/auth/me/route.ts
Normal file
16
project/apps/admin/src/app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookies = req.headers.get('cookie') ?? '';
|
||||
try {
|
||||
const backendRes = await fetch(`${API}/auth/me`, {
|
||||
headers: { Cookie: cookies },
|
||||
});
|
||||
if (!backendRes.ok) return NextResponse.json({ user: null });
|
||||
return NextResponse.json(await backendRes.json());
|
||||
} catch {
|
||||
return NextResponse.json({ user: null });
|
||||
}
|
||||
}
|
||||
44
project/apps/admin/src/app/api/upload/route.ts
Normal file
44
project/apps/admin/src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif'];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Tipo no permitido. Usa: ${ALLOWED_TYPES.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'El archivo excede 10MB' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Unique filename
|
||||
const ext = file.name.split('.').pop() ?? 'jpg';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||
const filePath = path.join(uploadDir, filename);
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url, filename, size: file.size });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: 'Error al subir el archivo' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
51
project/apps/admin/src/app/globals.css
Normal file
51
project/apps/admin/src/app/globals.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #2D6A4F;
|
||||
--color-primary-dark: #1B4332;
|
||||
--color-primary-light: #40916C;
|
||||
--color-secondary: #F5F0E8;
|
||||
--color-accent: #E76F51;
|
||||
--color-text: #111827;
|
||||
--color-muted: #6B7280;
|
||||
--color-border: #E5E7EB;
|
||||
--color-bg: #F9FAFB;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-danger: #DC2626;
|
||||
--color-warning: #D97706;
|
||||
--color-success: #059669;
|
||||
--font-sans: "Inter", system-ui, sans-serif;
|
||||
--font-heading: "Playfair Display", Georgia, serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #F9FAFB;
|
||||
--foreground: #111827;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #D1D5DB;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #9CA3AF;
|
||||
}
|
||||
18
project/apps/admin/src/app/layout.tsx
Normal file
18
project/apps/admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'MercadoDeVida Admin',
|
||||
template: '%s | MercadoDeVida Admin',
|
||||
},
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es" suppressHydrationWarning>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user