feat(F-163): completed feature
This commit is contained in:
128
project/apps/admin/src/app/(dashboard)/pos/page.tsx
Normal file
128
project/apps/admin/src/app/(dashboard)/pos/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
interface PosStore { id: string; name: string; active: boolean }
|
||||
interface PosTerminal {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
bindingCode: string | null;
|
||||
boundAt: string | null;
|
||||
status: string;
|
||||
lastSeenAt: string | null;
|
||||
}
|
||||
|
||||
export default function PosAdminPage() {
|
||||
const [stores, setStores] = useState<PosStore[]>([]);
|
||||
const [terminals, setTerminals] = useState<PosTerminal[]>([]);
|
||||
const [storeId, setStoreId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [copied, setCopied] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [storeData, terminalData] = await Promise.all([
|
||||
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
|
||||
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
|
||||
]);
|
||||
setStores(storeData.stores);
|
||||
setTerminals(terminalData.terminals);
|
||||
setStoreId((current) => current || storeData.stores.find((store) => store.active)?.id || '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo cargar la configuración TPV');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const createTerminal = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setCreating(true);
|
||||
setError('');
|
||||
setNewCode('');
|
||||
try {
|
||||
const terminal = await api.post<PosTerminal>('/api/pos/admin/terminals', { storeId, name: name.trim() });
|
||||
setName('');
|
||||
setNewCode(terminal.bindingCode ?? '');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'No se pudo crear el terminal');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyCode = async (code: string) => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(code);
|
||||
setTimeout(() => setCopied(''), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">TPV</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-500">Configura dispositivos y obtén el código para vincular cada caja.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-5 text-sm text-blue-900">
|
||||
<h2 className="font-bold">1. Vincular terminal</h2>
|
||||
<p className="mt-1">Crea un terminal aquí y escribe su código de 8 caracteres en el navegador del TPV. Esto identifica qué caja física está vendiendo.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-900">
|
||||
<h2 className="font-bold">2. Abrir sesión de caja</h2>
|
||||
<p className="mt-1">Después de vincular, el cajero indica el efectivo inicial en el propio TPV. La sesión representa el turno diario de caja.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={createTerminal} className="rounded-xl border border-gray-200 bg-white p-6">
|
||||
<h2 className="mb-4 font-bold text-gray-900">Crear terminal</h2>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_1fr_auto] md:items-end">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Tienda
|
||||
<select value={storeId} onChange={(event) => setStoreId(event.target.value)} required className="mt-1 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5">
|
||||
{stores.filter((store) => store.active).map((store) => <option key={store.id} value={store.id}>{store.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Nombre del terminal
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required maxLength={100} placeholder="Caja principal" className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2.5" />
|
||||
</label>
|
||||
<button type="submit" disabled={creating || !storeId || !name.trim()} className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50">
|
||||
{creating ? 'Creando…' : 'Crear terminal'}
|
||||
</button>
|
||||
</div>
|
||||
{newCode && <p className="mt-4 rounded-lg bg-green-50 p-3 text-sm text-green-800">Código creado: <strong className="font-mono text-lg tracking-widest">{newCode}</strong></p>}
|
||||
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div className="border-b border-gray-200 px-6 py-4"><h2 className="font-bold text-gray-900">Terminales</h2></div>
|
||||
{loading ? <p className="p-6 text-sm text-gray-500">Cargando…</p> : terminals.length === 0 ? <p className="p-6 text-sm text-gray-500">No hay terminales.</p> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500"><tr><th className="px-4 py-3">Nombre</th><th className="px-4 py-3">Tienda</th><th className="px-4 py-3">Código</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3">Vinculado</th></tr></thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{terminals.map((terminal) => {
|
||||
const store = stores.find((item) => item.id === terminal.storeId);
|
||||
return <tr key={terminal.id}><td className="px-4 py-3 font-medium">{terminal.name}</td><td className="px-4 py-3">{store?.name ?? terminal.storeId}</td><td className="px-4 py-3"><button type="button" onClick={() => terminal.bindingCode && void copyCode(terminal.bindingCode)} disabled={!terminal.bindingCode} className="rounded-lg bg-gray-100 px-3 py-1 font-mono tracking-wider hover:bg-gray-200 disabled:opacity-40">{copied === terminal.bindingCode ? 'Copiado' : terminal.bindingCode ?? '—'}</button></td><td className="px-4 py-3 capitalize">{terminal.status}</td><td className="px-4 py-3">{terminal.boundAt ? 'Sí' : 'No'}</td></tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface NavItem {
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: '/', label: 'Dashboard', icon: '📊', permission: 'dashboard' },
|
||||
{ href: '/reporting', label: 'Reporting', icon: '📈', permission: 'reporting.read' },
|
||||
{ href: '/pos', label: 'TPV', icon: '🛒', permission: 'dashboard' },
|
||||
{ href: '/products', label: 'Productos', icon: '📦', permission: 'products.read' },
|
||||
{ href: '/orders', label: 'Pedidos', icon: '🧾', permission: 'orders.read' },
|
||||
{ href: '/payments', label: 'Pagos', icon: '💳', permission: 'orders.read' },
|
||||
|
||||
Reference in New Issue
Block a user