feat(F-187): completed feature
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user