313 lines
15 KiB
TypeScript
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' }],
|
|
};
|