feat(F-187): completed feature

This commit is contained in:
chattie
2026-08-22 22:23:44 +02:00
parent a3f6edd325
commit ca12f46bff
22 changed files with 1053 additions and 59 deletions

View File

@@ -35,6 +35,16 @@ interface PaymentMethod {
active: boolean;
sortOrder: number;
}
interface PosCashier {
id: string;
email: string;
role: 'pos_cashier';
active: boolean;
deactivatedAt: string | null;
deletedAt: string | null;
createdAt: string;
status: 'active' | 'inactive' | 'deleted';
}
interface ReceiptSettings {
storeId: string;
name: string;
@@ -85,6 +95,10 @@ export default function PosAdminPage() {
const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState('');
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [cashiers, setCashiers] = useState<PosCashier[]>([]);
const [newCashier, setNewCashier] = useState({ email: '', password: '' });
const [savingCashier, setSavingCashier] = useState(false);
const [cashierMessage, setCashierMessage] = useState('');
const [newMethod, setNewMethod] = useState({
code: '',
label: '',
@@ -118,14 +132,16 @@ export default function PosAdminPage() {
setLoading(true);
setError('');
try {
const [storeData, terminalData, catalogData] = await Promise.all([
const [storeData, terminalData, catalogData, userData] = await Promise.all([
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
]);
setStores(storeData.stores);
setTerminals(terminalData.terminals);
setCatalogOptions(catalogData.items);
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
setStoreId(selected);
await loadStoreConfiguration(selected);
@@ -229,6 +245,57 @@ export default function PosAdminPage() {
}
};
const createCashier = async (event: React.FormEvent) => {
event.preventDefault();
setSavingCashier(true);
setCashierMessage('');
try {
await api.post('/api/pos/users', {
email: newCashier.email.trim().toLowerCase(),
password: newCashier.password,
role: 'pos_cashier',
});
setNewCashier({ email: '', password: '' });
setCashierMessage('Cajero creado');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : 'No se pudo crear el cajero');
} finally {
setSavingCashier(false);
}
};
const toggleCashier = async (cashier: PosCashier) => {
const nextActive = !cashier.active;
const action = nextActive ? 'reactivar' : 'desactivar';
if (!window.confirm(`¿Quieres ${action} a ${cashier.email}?`)) return;
setCashierMessage('');
try {
await api.patch(`/api/pos/users/${cashier.id}/status`, { active: nextActive });
setCashierMessage(nextActive ? 'Cajero reactivado' : 'Cajero desactivado');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : `No se pudo ${action} el cajero`);
}
};
const deleteCashier = async (cashier: PosCashier) => {
if (
!window.confirm(
`¿Eliminar definitivamente a ${cashier.email}? No podrá reactivarse. Su historial de ventas y caja se conservará.`,
)
)
return;
setCashierMessage('');
try {
await api.delete(`/api/pos/users/${cashier.id}`);
setCashierMessage('Cajero eliminado; su histórico se conserva');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : 'No se pudo eliminar el cajero');
}
};
const saveReceipt = async (event: React.FormEvent) => {
event.preventDefault();
setSavingReceipt(true);
@@ -362,6 +429,116 @@ export default function PosAdminPage() {
)}
</section>
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div>
<h2 className="font-bold">Cajeros</h2>
<p className="mt-1 text-sm text-gray-500">
Desactivar corta el acceso y permite reactivarlo. Eliminar es irreversible, pero
conserva la atribución histórica de ventas y sesiones de caja.
</p>
</div>
<form
onSubmit={createCashier}
className="mt-5 grid gap-3 rounded-xl bg-gray-50 p-4 md:grid-cols-[1fr_1fr_auto] md:items-end"
>
<label className="text-sm font-medium">
Email
<input
type="email"
value={newCashier.email}
onChange={(event) => setNewCashier({ ...newCashier, email: event.target.value })}
required
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<label className="text-sm font-medium">
Contraseña inicial
<input
type="password"
value={newCashier.password}
onChange={(event) => setNewCashier({ ...newCashier, password: event.target.value })}
required
minLength={8}
maxLength={200}
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<button
disabled={savingCashier}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{savingCashier ? 'Creando…' : 'Crear cajero'}
</button>
</form>
<div className="mt-5 overflow-x-auto rounded-xl border">
<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">Email</th>
<th className="px-4 py-3">Estado</th>
<th className="px-4 py-3">Creado</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{cashiers.map((cashier) => (
<tr key={cashier.id} className={cashier.status === 'deleted' ? 'opacity-60' : ''}>
<td className="px-4 py-3 font-medium">{cashier.email}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
cashier.status === 'active'
? 'bg-green-100 text-green-800'
: cashier.status === 'inactive'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-200 text-gray-700'
}`}
>
{cashier.status === 'active'
? 'Activo'
: cashier.status === 'inactive'
? 'Inactivo'
: 'Eliminado'}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{new Date(cashier.createdAt).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
{cashier.status !== 'deleted' && (
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => void toggleCashier(cashier)}
className="rounded-lg border px-3 py-2 text-xs font-semibold"
>
{cashier.active ? 'Desactivar' : 'Reactivar'}
</button>
<button
type="button"
onClick={() => void deleteCashier(cashier)}
className="rounded-lg border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
>
Eliminar
</button>
</div>
)}
</td>
</tr>
))}
{!loading && cashiers.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No hay cajeros configurados.
</td>
</tr>
)}
</tbody>
</table>
</div>
{cashierMessage && <p className="mt-3 text-sm">{cashierMessage}</p>}
</section>
{configuring && (
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex justify-between">