Files
compras/resources/js/pages/stock/index.tsx
T

313 lines
15 KiB
TypeScript

import { Head, Link, useForm } from '@inertiajs/react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import {
FormErrors,
PageTitle,
Panel,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { date } from '@/types/procurement';
import { quantity } from '@/types/inventory';
import type {
Page,
StockItem,
StockMovement,
Warehouse,
} from '@/types/inventory';
type Props = {
items: StockItem[];
warehouses: Warehouse[];
balances: {
stock_item_id: number;
warehouse_id: number;
quantity_milli: number;
}[];
movements: Page<StockMovement>;
filters: { search?: string; warehouse_id?: number };
canManage: boolean;
};
export default function Stock({
items,
warehouses,
balances,
movements,
filters,
canManage,
}: Props) {
const search = useForm({
search: filters.search ?? '',
warehouse_id: filters.warehouse_id ? String(filters.warehouse_id) : '',
});
const item = useForm({ code: '', name: '', unit: '' });
const warehouse = useForm({ code: '', name: '' });
return (
<>
<Head title="Stock e movimentos" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Armazém"
title="Existências e movimentos"
description="Saldo por artigo e armazém, calculado a partir de entradas, saídas, transferências e ajustes confirmados. O histórico permanece preservado."
/>
<InventoryNotice />
<Link
href="/stock/operacoes"
className="font-semibold text-teal-700"
>
Saídas, transferências e inventários →
</Link>
<Panel title="Existências">
<form
className="mb-5 flex flex-wrap gap-3"
onSubmit={(e) => {
e.preventDefault();
search.get('/stock', { preserveState: true });
}}
>
<Input
className="max-w-sm"
aria-label="Pesquisar artigo"
placeholder="Código ou nome do artigo"
value={search.data.search}
onChange={(e) =>
search.setData('search', e.target.value)
}
/>
<select
className={`${controlClass} max-w-xs`}
aria-label="Filtrar armazém"
value={search.data.warehouse_id}
onChange={(e) =>
search.setData('warehouse_id', e.target.value)
}
>
<option value="">Todos os armazéns</option>
{warehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.code} · {w.name}
</option>
))}
</select>
<Button variant="outline">Filtrar</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pr-4 pb-3">Artigo</th>
<th className="pr-4">Armazém</th>
<th>Quantidade em stock</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => {
const article = items.find(
(i) => i.id === balance.stock_item_id,
);
return (
<tr
className="border-b last:border-0"
key={`${balance.stock_item_id}-${balance.warehouse_id}`}
>
<td className="py-4 pr-4">
<strong>{article?.code}</strong>{' '}
· {article?.name}
</td>
<td className="pr-4">
{
warehouses.find(
(w) =>
w.id ===
balance.warehouse_id,
)?.name
}
</td>
<td className="font-medium">
{quantity(
balance.quantity_milli,
)}{' '}
{article?.unit}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{balances.length === 0 && (
<p className="py-6 text-sm text-muted-foreground">
Não há entradas confirmadas para estes filtros.
Criar um artigo não cria stock.
</p>
)}
</Panel>
<Panel title="Histórico de entradas">
<div className="space-y-4">
{movements.data.map((m) => (
<div
key={m.id}
className="flex flex-wrap justify-between gap-3 rounded-lg border p-4 text-sm"
>
<div>
<strong>
{m.quantity_milli > 0 ? '+' : ''}
{quantity(m.quantity_milli)}{' '}
{m.snapshot.stock_item.unit} ·{' '}
{m.snapshot.stock_item.code}
</strong>
<p className="mt-1 text-muted-foreground">
{m.snapshot.stock_item.name} ·{' '}
{m.snapshot.warehouse.name}
{m.snapshot.equipment &&
` · Equipamento ${m.snapshot.equipment.code}`}
</p>
<p className="mt-1 text-xs">
{date(m.moved_at)} · Utilizador #
{m.moved_by} ·{' '}
{m.snapshot.reference ??
m.snapshot.order_number}{' '}
· {m.movement_type}
{m.snapshot.delivery_note &&
` · Guia ${m.snapshot.delivery_note}`}
</p>
</div>
<Link
href={
m.snapshot.receipt_id
? `/recepcoes/${m.snapshot.receipt_id}`
: '/stock/operacoes'
}
className="text-teal-700 dark:text-teal-400"
>
{m.snapshot.receipt_id
? 'Abrir recepção'
: 'Abrir operações'}
</Link>
</div>
))}
</div>
{movements.data.length === 0 && (
<p className="text-sm text-muted-foreground">
Ainda não há movimentos disponíveis.
</p>
)}
<InventoryPagination page={movements} />
<p className="mt-4 text-xs text-muted-foreground">
Movimentos confirmados preservados. As transferências
têm duas linhas ligadas à mesma operação.
</p>
</Panel>
<div className="grid gap-6 lg:grid-cols-2">
<Panel
title="Catálogo de artigos"
description="Identificação explícita dos artigos: descrições iguais não são agregadas automaticamente."
>
<div className="mb-5 space-y-2 text-sm">
{items.map((i) => (
<p key={i.id}>
<strong>{i.code}</strong> · {i.name} ·{' '}
{i.unit}
</p>
))}
</div>
{canManage && (
<form
className="space-y-4 border-t pt-4"
onSubmit={(e) => {
e.preventDefault();
item.post('/stock/artigos', {
preserveScroll: true,
onSuccess: () => item.reset(),
});
}}
>
<FormErrors errors={item.errors} />
{(['code', 'name', 'unit'] as const).map(
(key) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`stock-${key}`}>
{key === 'code'
? 'Código'
: key === 'name'
? 'Designação'
: 'Unidade (igual à ordem)'}
</Label>
<Input
id={`stock-${key}`}
required
value={item.data[key]}
onChange={(e) =>
item.setData(
key,
e.target.value,
)
}
/>
</div>
),
)}
<Button disabled={item.processing}>
Criar artigo sem saldo inicial
</Button>
</form>
)}
</Panel>
<Panel title="Armazéns">
<div className="mb-5 space-y-2 text-sm">
{warehouses.map((w) => (
<p key={w.id}>
<strong>{w.code}</strong> · {w.name}
</p>
))}
</div>
{canManage && (
<form
className="space-y-4 border-t pt-4"
onSubmit={(e) => {
e.preventDefault();
warehouse.post('/stock/armazens', {
preserveScroll: true,
onSuccess: () => warehouse.reset(),
});
}}
>
<FormErrors errors={warehouse.errors} />
{(['code', 'name'] as const).map((key) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`warehouse-${key}`}>
{key === 'code'
? 'Código'
: 'Nome do armazém'}
</Label>
<Input
id={`warehouse-${key}`}
required
value={warehouse.data[key]}
onChange={(e) =>
warehouse.setData(
key,
e.target.value,
)
}
/>
</div>
))}
<Button disabled={warehouse.processing}>
Criar armazém
</Button>
</form>
)}
</Panel>
</div>
</div>
</>
);
}
Stock.layout = {
breadcrumbs: [{ title: 'Stock e movimentos', href: '/stock' }],
};