Consolida recepcoes, stock, equipamentos e identidade SGC

This commit is contained in:
2026-10-28 13:54:53 +02:00
parent eb1c8b8074
commit 75ffdb395c
76 changed files with 6933 additions and 152 deletions
+2 -1
View File
@@ -6,13 +6,14 @@ import AppLayout from '@/layouts/app-layout';
import AuthLayout from '@/layouts/auth-layout';
import SettingsLayout from '@/layouts/settings/layout';
const appName = 'ProcuraFlow';
const appName = 'SGC';
void createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => {
switch (true) {
case name === 'welcome':
case name === 'home':
case name.startsWith('preview/'):
return null;
case name.startsWith('auth/'):
+1 -1
View File
@@ -8,7 +8,7 @@ export default function AppLogo() {
</div>
<div className="ml-1 grid flex-1 text-left text-sm">
<span className="mb-0.5 truncate leading-tight font-semibold">
ProcuraFlow
SGC
</span>
</div>
</>
+80 -15
View File
@@ -7,6 +7,8 @@ import {
Scale,
ShieldAlert,
FileCheck2,
PackageCheck,
Warehouse,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
import { NavMain } from '@/components/nav-main';
@@ -23,42 +25,89 @@ import {
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
const mainNavItems: NavItem[] = [
const generalNavItems: NavItem[] = [
{
title: 'Visão geral',
href: dashboard(),
icon: LayoutGrid,
},
{ title: 'Requisições', href: '/requisicoes', icon: ClipboardList },
{ title: 'Conflitos de interesses', href: '/conflitos', icon: ShieldAlert },
];
export function AppSidebar() {
const { auth } = usePage().props;
const purchasingItems = [
const canReadPurchases = [
'admin',
'procurement',
'finance',
'department_head',
].includes(auth.user.purchasing_role ?? '')
? [
...mainNavItems,
{ title: 'Fornecedores', href: '/fornecedores', icon: Users },
{ title: 'Cotações', href: '/cotacoes', icon: Scale },
{ title: 'Ordens de compra', href: '/ordens', icon: FileCheck2 },
]
: mainNavItems;
const items =
].includes(auth.user.purchasing_role ?? '');
const purchasingItems: NavItem[] = [
{ title: 'Requisições', href: '/requisicoes', icon: ClipboardList },
...(canReadPurchases
? [
{ title: 'Fornecedores', href: '/fornecedores', icon: Users },
{ title: 'Cotações', href: '/cotacoes', icon: Scale },
{
title: 'Ordens de compra',
href: '/ordens',
icon: FileCheck2,
},
]
: []),
];
const inventoryItems = [
...([
'admin',
'procurement',
'finance',
'warehouse',
'department_head',
].includes(auth.user.purchasing_role ?? '')
? [{ title: 'Recepção', href: '/recepcoes', icon: PackageCheck }]
: []),
...([
'admin',
'procurement',
'finance',
'warehouse',
'department_head',
].includes(auth.user.purchasing_role ?? '')
? [
{
title: 'Equipamentos e peças',
href: '/equipamentos',
icon: Warehouse,
},
]
: []),
...(['admin', 'procurement', 'finance', 'warehouse'].includes(
auth.user.purchasing_role ?? '',
)
? [
{
title: 'Stock e movimentos',
href: '/stock',
icon: Warehouse,
},
{
title: 'Saídas e inventários',
href: '/stock/operacoes',
icon: ClipboardList,
},
]
: []),
];
const administrationItems =
auth.user.purchasing_role === 'admin'
? [
...purchasingItems,
{
title: 'Empresa e equipa',
href: '/empresa',
icon: Building2,
},
]
: purchasingItems;
: [];
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
@@ -74,10 +123,26 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={items} />
<NavMain title="Geral" items={generalNavItems} />
<NavMain title="Compras" items={purchasingItems} />
{inventoryItems.length > 0 && (
<NavMain title="Armazém" items={inventoryItems} />
)}
{administrationItems.length > 0 && (
<NavMain
title="Administração"
items={administrationItems}
/>
)}
</SidebarContent>
<SidebarFooter>
<Link
href="/"
className="px-3 py-2 text-xs text-muted-foreground hover:text-foreground"
>
Página inicial
</Link>
<NavUser />
</SidebarFooter>
</Sidebar>
+85
View File
@@ -0,0 +1,85 @@
import { Link } from '@inertiajs/react';
import type { Page, ReceiptProgress } from '@/types/inventory';
import { quantity, receiptStates } from '@/types/inventory';
export function InventoryNotice() {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Ambiente de desenvolvimento. Não autoriza compras reais.{' '}
<strong>O orçamento ainda não é validado.</strong>
</div>
);
}
export function ReceiptProgressTable({
progress,
}: {
progress: ReceiptProgress;
}) {
return (
<>
<p className="mb-4 text-sm font-semibold">
{receiptStates[progress.state]}
{progress.has_divergences
? ' · Existem divergências de qualidade'
: ''}
</p>
<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">Autorizado</th>
<th className="pr-4">Recebido</th>
<th className="pr-4">Aceite</th>
<th className="pr-4">Rejeitado</th>
<th className="pr-4">Por entregar</th>
<th>Por aceitar</th>
</tr>
</thead>
<tbody>
{progress.lines.map((line) => (
<tr
key={line.id}
className="border-b last:border-0"
>
<td className="py-4 pr-4">
<strong>{line.description}</strong>
<p className="mt-1 text-xs text-muted-foreground">
{line.unit}
</p>
</td>
{[
line.ordered_milli,
line.received_milli,
line.accepted_milli,
line.rejected_milli,
line.delivery_pending_milli,
line.acceptance_pending_milli,
].map((value, index) => (
<td
key={index}
className="pr-4 whitespace-nowrap"
>
{quantity(value)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
export function InventoryPagination<T>({ page }: { page: Page<T> }) {
return (
<div className="mt-4 flex gap-4 text-sm text-teal-700 dark:text-teal-400">
{page.prev_page_url && (
<Link href={page.prev_page_url}>Anterior</Link>
)}
{page.next_page_url && (
<Link href={page.next_page_url}>Seguinte</Link>
)}
</div>
);
}
+20 -4
View File
@@ -7,20 +7,36 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/hooks/use-current-url';
import { toUrl } from '@/lib/utils';
import type { NavItem } from '@/types';
export function NavMain({ items }: { items: NavItem[] }) {
const { isCurrentUrl } = useCurrentUrl();
export function NavMain({
items,
title = 'Navegação',
}: {
items: NavItem[];
title?: string;
}) {
const { isCurrentOrParentUrl, currentUrl } = useCurrentUrl();
const receivingOrder = /^\/ordens\/\d+\/recepcoes/.test(currentUrl);
return (
<SidebarGroup className="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isCurrentUrl(item.href)}
isActive={
(toUrl(item.href) === '/recepcoes' &&
receivingOrder) ||
(isCurrentOrParentUrl(item.href) &&
!(
toUrl(item.href) === '/ordens' &&
receivingOrder
))
}
tooltip={{ children: item.title }}
>
<Link href={item.href} prefetch>
+13
View File
@@ -0,0 +1,13 @@
export const previewPaths: Record<string, string> = {
overview: '/consulta',
requests: '/consulta/requisicoes',
suppliers: '/consulta/fornecedores',
quotes: '/consulta/cotacoes',
conflicts: '/consulta/conflitos',
orders: '/consulta/ordens',
receipts: '/consulta/recepcoes',
stock: '/consulta/stock',
operations: '/consulta/operacoes-stock',
equipment: '/consulta/equipamentos',
appearance: '/consulta/aparencia',
};
+1 -1
View File
@@ -117,6 +117,6 @@ export default function Login({ status, canResetPassword }: Props) {
}
Login.layout = {
title: 'Entrar no ProcuraFlow',
title: 'Entrar no Sistema de Gestão de Compras',
description: 'Introduza o seu email e a palavra-passe',
};
+1 -1
View File
@@ -61,7 +61,7 @@ export default function Dashboard({
<Head title="Visão geral" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={company?.name ?? 'Bem-vindo ao ProcuraFlow'}
eyebrow={company?.name ?? 'Bem-vindo ao SGC'}
title="Visão geral das compras"
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
action={
+859
View File
@@ -0,0 +1,859 @@
import { Head, useForm, Link } from '@inertiajs/react';
import { useState } from 'react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { quantity } from '@/types/inventory';
import type { StockItem, Page } from '@/types/inventory';
type Level = {
id: number;
code: string;
name: string;
criteria: string;
is_critical: boolean;
};
type Asset = {
id: number;
code: string;
description: string;
area: string;
serial_number: string | null;
status: string;
department_id: number;
stock_item_id: number | null;
criticality_level_id: number;
version: number;
};
type Part = {
id: number;
stock_item_id: number;
criticality_level_id: number;
minimum_milli: number;
balance_milli: number;
below_minimum: boolean;
version: number;
};
type History = {
id: number;
reference: string;
quantity_milli: number;
snapshot: {
equipment?: { code: string; description: string };
stock_item: { name: string; unit: string };
reason?: string;
};
};
type Props = {
equipment: Asset[];
parts: Part[];
levels: Level[];
links: { equipment_id: number; spare_part_id: number }[];
items: StockItem[];
departments: { id: number; name: string }[];
history: Page<History>;
canManage: boolean;
canConfigure: boolean;
demo?: boolean;
};
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<label className="grid gap-2 text-sm">
{label}
{children}
</label>
);
}
const statuses: Record<string, string> = {
active: 'Em serviço',
stopped: 'Parado',
retired: 'Retirado',
};
export default function EquipmentPage({
equipment,
parts,
levels,
links,
items,
departments,
history,
canManage,
canConfigure,
demo = false,
}: Props) {
const [message, setMessage] = useState('');
const category = useForm({
code: '',
name: '',
criteria: '',
is_critical: '',
});
const asset = useForm({
id: '',
version: '',
code: '',
description: '',
area: '',
serial_number: '',
department_id: '',
stock_item_id: '',
criticality_level_id: '',
status: 'active',
});
const part = useForm({
id: '',
version: '',
stock_item_id: '',
criticality_level_id: '',
minimum_quantity: '',
});
const association = useForm({ equipment_id: '', spare_part_id: '' });
const stock = (id: number) => items.find((i) => i.id === id);
const criticality = (id: number) => levels.find((l) => l.id === id);
function simulate() {
setMessage(
'Simulação revista. Nenhum cadastro ou movimento real foi criado.',
);
}
return (
<>
<Head title="Equipamentos e peças" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Produção e armazém"
title="Equipamentos e peças sobressalentes"
description="Cadastro, peças compatíveis, mínimos explícitos e rastreabilidade das saídas."
/>
<InventoryNotice />
{demo && (
<p className="rounded-lg border bg-teal-50 p-4 text-sm text-teal-900">
Dados fictícios para revisão sem login. Formulários
apenas de simulação.
</p>
)}
<Panel
title="Regras e validação técnica"
description="Os critérios de criticidade e mínimos são definidos explicitamente pela empresa, sem valores predefinidos."
>
<p className="text-sm">
O responsável da área (chefe do departamento), distinto
do solicitante, valida tecnicamente a versão do pedido
de equipamento. Finanças é obrigatória para qualquer
valor. A adjudicação e emissão de equipamento permanecem
bloqueadas: o limite próprio de investimento e a sua
aplicação exigem decisão da empresa. Uma aprovação
técnica não resolve essa pendência.
</p>
<Link
className="mt-3 inline-block text-sm font-semibold text-teal-700"
href={demo ? '/consulta/requisicoes' : '/requisicoes'}
>
Consultar requisições →
</Link>
</Panel>
<Panel title="Equipamentos">
<div className="grid gap-4 lg:grid-cols-2">
{equipment.map((e) => (
<article
className="rounded-lg border p-4 text-sm"
key={e.id}
>
<strong>
{e.code} · {e.description}
</strong>
<p className="mt-2">
{e.area} ·{' '}
{
departments.find(
(d) => d.id === e.department_id,
)?.name
}{' '}
· {statuses[e.status]}
</p>
<p>
Série: {e.serial_number || 'Não indicada'} ·
Criticidade:{' '}
{criticality(e.criticality_level_id)?.name}
</p>
{e.stock_item_id && (
<p>
Artigo: {stock(e.stock_item_id)?.code}
</p>
)}
<p className="mt-2">
Peças associadas:{' '}
{links
.filter((l) => l.equipment_id === e.id)
.map(
(l) =>
stock(
parts.find(
(p) =>
p.id ===
l.spare_part_id,
)?.stock_item_id ?? 0,
)?.code,
)
.join(', ') || 'Nenhuma'}
</p>
{canManage && (
<Button
className="mt-3"
variant="outline"
onClick={() =>
asset.setData({
id: String(e.id),
version: String(e.version),
code: e.code,
description: e.description,
area: e.area,
serial_number:
e.serial_number ?? '',
department_id: String(
e.department_id,
),
stock_item_id: e.stock_item_id
? String(e.stock_item_id)
: '',
criticality_level_id: String(
e.criticality_level_id,
),
status: e.status,
})
}
>
Editar ficha
</Button>
)}
</article>
))}
</div>
{equipment.length === 0 && (
<p className="text-sm">
Sem equipamentos. Configure a criticidade antes do
cadastro.
</p>
)}
</Panel>
<Panel
title="Peças e alertas de mínimo"
description="Saldo total por artigo nos armazéns da empresa. Cada peça reutiliza um artigo existente; associá-la a várias máquinas não duplica o stock."
>
<div className="space-y-3">
{parts.map((p) => (
<article
key={p.id}
className={`rounded-lg border p-4 text-sm ${p.below_minimum ? 'border-amber-300 bg-amber-50 text-amber-950' : ''}`}
>
<strong>
{stock(p.stock_item_id)?.code} ·{' '}
{stock(p.stock_item_id)?.name}
</strong>
<p className="mt-2">
Stock: {quantity(p.balance_milli)} · Mínimo
definido: {quantity(p.minimum_milli)}{' '}
{stock(p.stock_item_id)?.unit} ·{' '}
{criticality(p.criticality_level_id)?.name}
</p>
<p className="mt-1 font-semibold">
{p.below_minimum
? 'Abaixo do mínimo — rever reposição'
: 'Mínimo assegurado'}
</p>
<p>
Critério:{' '}
{
criticality(p.criticality_level_id)
?.criteria
}
</p>
{canManage && (
<Button
className="mt-3"
variant="outline"
onClick={() =>
part.setData({
id: String(p.id),
version: String(p.version),
stock_item_id: String(
p.stock_item_id,
),
criticality_level_id: String(
p.criticality_level_id,
),
minimum_quantity: (
p.minimum_milli / 1000
).toFixed(3),
})
}
>
Editar mínimo e criticidade
</Button>
)}
</article>
))}
</div>
{parts.length === 0 && (
<p className="text-sm">Sem peças registadas.</p>
)}
</Panel>
{canManage && (
<div className="grid gap-6 lg:grid-cols-2">
<Panel
title={
asset.data.id
? 'Editar equipamento'
: 'Cadastrar equipamento'
}
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
if (demo) {
simulate();
return;
}
asset.post('/equipamentos/activos', {
onSuccess: () => asset.reset(),
});
}}
>
<FormErrors errors={asset.errors} />
{(
[
'code',
'description',
'area',
'serial_number',
] as const
).map((k) => (
<Field
key={k}
label={
{
code: 'Código',
description: 'Descrição',
area: 'Área / linha de produção',
serial_number:
'Número de série',
}[k]
}
>
<Input
required={k !== 'serial_number'}
value={asset.data[k]}
onChange={(e) =>
asset.setData(k, e.target.value)
}
/>
</Field>
))}
<Field label="Departamento">
<select
required
className={controlClass}
value={asset.data.department_id}
onChange={(e) =>
asset.setData(
'department_id',
e.target.value,
)
}
>
<option value="">Seleccionar</option>
{departments.map((d) => (
<option key={d.id} value={d.id}>
{d.name}
</option>
))}
</select>
</Field>
<Field label="Artigo de stock (opcional)">
<select
className={controlClass}
value={asset.data.stock_item_id}
onChange={(e) =>
asset.setData(
'stock_item_id',
e.target.value,
)
}
>
<option value="">
Sem artigo associado
</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.code} · {i.name}
</option>
))}
</select>
</Field>
<Field label="Criticidade definida pela empresa">
<select
required
className={controlClass}
value={asset.data.criticality_level_id}
onChange={(e) =>
asset.setData(
'criticality_level_id',
e.target.value,
)
}
>
<option value="">Seleccionar</option>
{levels.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</Field>
<Field label="Estado">
<select
className={controlClass}
value={asset.data.status}
onChange={(e) =>
asset.setData(
'status',
e.target.value,
)
}
>
{Object.entries(statuses).map(
([k, v]) => (
<option key={k} value={k}>
{v}
</option>
),
)}
</select>
</Field>
<Button
disabled={
asset.processing || levels.length === 0
}
>
Guardar equipamento
</Button>
{asset.data.id && (
<Button
type="button"
variant="outline"
onClick={() => asset.reset()}
>
Novo cadastro
</Button>
)}
<p className="text-xs text-muted-foreground">
O cadastro não cria entradas nem retira
existências. Os movimentos usam o fluxo de
stock.
</p>
</form>
</Panel>
<div className="space-y-6">
<Panel
title={
part.data.id
? 'Editar peça'
: 'Cadastrar peça sobre artigo existente'
}
>
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
if (demo) {
simulate();
return;
}
part.post('/equipamentos/pecas', {
onSuccess: () => part.reset(),
});
}}
>
<FormErrors errors={part.errors} />
<Field label="Artigo / referência">
<select
required
disabled={!!part.data.id}
className={controlClass}
value={part.data.stock_item_id}
onChange={(e) =>
part.setData(
'stock_item_id',
e.target.value,
)
}
>
<option value="">
Seleccionar
</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.code} · {i.name} (
{i.unit})
</option>
))}
</select>
</Field>
<Field label="Criticidade">
<select
required
className={controlClass}
value={
part.data.criticality_level_id
}
onChange={(e) =>
part.setData(
'criticality_level_id',
e.target.value,
)
}
>
<option value="">
Seleccionar
</option>
{levels.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</Field>
<Field label="Quantidade mínima explícita (pode ser zero)">
<Input
required
type="number"
min="0"
step="0.001"
value={part.data.minimum_quantity}
onChange={(e) =>
part.setData(
'minimum_quantity',
e.target.value,
)
}
/>
</Field>
<Button
disabled={
part.processing ||
levels.length === 0
}
>
Guardar peça
</Button>
{part.data.id && (
<Button
type="button"
variant="outline"
onClick={() => part.reset()}
>
Nova peça
</Button>
)}
</form>
</Panel>
<Panel title="Associar peça a equipamento">
<form
className="grid gap-4"
onSubmit={(e) => {
e.preventDefault();
if (demo) {
simulate();
return;
}
association.post(
'/equipamentos/associacoes',
{
onSuccess: () =>
association.reset(),
},
);
}}
>
<FormErrors errors={association.errors} />
<Field label="Equipamento">
<select
required
className={controlClass}
value={
association.data.equipment_id
}
onChange={(e) =>
association.setData(
'equipment_id',
e.target.value,
)
}
>
<option value="">
Seleccionar
</option>
{equipment.map((e) => (
<option key={e.id} value={e.id}>
{e.code} · {e.description}
</option>
))}
</select>
</Field>
<Field label="Peça">
<select
required
className={controlClass}
value={
association.data.spare_part_id
}
onChange={(e) =>
association.setData(
'spare_part_id',
e.target.value,
)
}
>
<option value="">
Seleccionar
</option>
{parts.map((p) => (
<option key={p.id} value={p.id}>
{
stock(p.stock_item_id)
?.code
}{' '}
·{' '}
{
stock(p.stock_item_id)
?.name
}
</option>
))}
</select>
</Field>
<Button disabled={association.processing}>
Associar sem duplicar stock
</Button>
</form>
</Panel>
</div>
</div>
)}
{canConfigure && (
<Panel
title="Configurar critérios de criticidade"
description="Sem categorias automáticas. Registe a decisão da empresa; os critérios guardados não são reescritos. Para novos critérios, crie uma nova categoria."
>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
if (demo) {
simulate();
return;
}
category.post('/equipamentos/categorias', {
onSuccess: () => category.reset(),
});
}}
>
<Field label="Código">
<Input
required
value={category.data.code}
onChange={(e) =>
category.setData('code', e.target.value)
}
/>
</Field>
<Field label="Nome da categoria">
<Input
required
value={category.data.name}
onChange={(e) =>
category.setData('name', e.target.value)
}
/>
</Field>
<Field label="Critério e fundamento da classificação">
<textarea
required
className={controlClass}
value={category.data.criteria}
onChange={(e) =>
category.setData(
'criteria',
e.target.value,
)
}
/>
</Field>
<Field label="É crítica para produção segundo este critério?">
<select
required
className={controlClass}
value={category.data.is_critical}
onChange={(e) =>
category.setData(
'is_critical',
e.target.value,
)
}
>
<option value="">Decisão explícita</option>
<option value="1">Sim</option>
<option value="0">Não</option>
</select>
</Field>
<FormErrors errors={category.errors} />
<Button disabled={category.processing}>
Guardar critério
</Button>
</form>
<div className="mt-4 space-y-2 text-sm">
{levels.map((l) => (
<p key={l.id}>
<strong>
{l.code} · {l.name}
</strong>
: {l.criteria} ·{' '}
{l.is_critical ? 'Crítica' : 'Não crítica'}
</p>
))}
</div>
</Panel>
)}
<p role="status" className="text-sm">
{message}
</p>
<Panel title="Saídas rastreáveis por equipamento">
<Link
href={
demo
? '/consulta/operacoes-stock'
: '/stock/operacoes'
}
className="text-sm font-semibold text-teal-700"
>
Registar saída com equipamento de destino →
</Link>
<div className="mt-4 space-y-3">
{history.data.map((h) => (
<article
key={h.id}
className="rounded-lg border p-4 text-sm"
>
<strong>
{h.reference} · {h.snapshot.equipment?.code}
</strong>
<p>
{h.snapshot.equipment?.description} ·{' '}
{h.snapshot.stock_item.name} ·{' '}
{quantity(h.quantity_milli)}{' '}
{h.snapshot.stock_item.unit}
</p>
<p>{h.snapshot.reason}</p>
</article>
))}
{history.data.length === 0 && (
<p className="text-sm">
Sem saídas associadas a equipamentos.
</p>
)}
</div>
<InventoryPagination page={history} />
</Panel>
</div>
</>
);
}
export function EquipmentDemo() {
return (
<EquipmentPage
demo
canManage
canConfigure
equipment={[
{
id: 1,
code: 'EQ-DEMO-01',
description: 'Compressor fictício',
area: 'Linha de produção fictícia A',
serial_number: 'SERIE-DEMO',
status: 'stopped',
department_id: 1,
stock_item_id: null,
criticality_level_id: 1,
version: 1,
},
]}
parts={[
{
id: 1,
stock_item_id: 1,
criticality_level_id: 1,
minimum_milli: 3000,
balance_milli: 1000,
below_minimum: true,
version: 1,
},
]}
links={[{ equipment_id: 1, spare_part_id: 1 }]}
levels={[
{
id: 1,
code: 'CRIT-DEMO',
name: 'Crítica (exemplo fictício)',
criteria:
'Exemplo apenas: falha interrompe a linha fictícia. A empresa deve definir os seus critérios.',
is_critical: true,
},
]}
items={[
{
id: 1,
code: 'ROL-DEMO',
name: 'Rolamento fictício',
unit: 'un',
},
]}
departments={[{ id: 1, name: 'Produção fictícia' }]}
history={{
data: [
{
id: 1,
reference: 'SAI-DEMO-PECA',
quantity_milli: 1000,
snapshot: {
equipment: {
code: 'EQ-DEMO-01',
description:
'Compressor fictício no momento da saída',
},
stock_item: {
name: 'Rolamento fictício',
unit: 'un',
},
reason: 'Substituição de peça numa paragem fictícia.',
},
},
],
prev_page_url: null,
next_page_url: null,
}}
/>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { Head, Link } from '@inertiajs/react';
import {
ArrowRight,
ClipboardList,
FileCheck2,
PackageCheck,
ShieldCheck,
Users,
Warehouse,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
const areas = [
{
title: 'Pedidos e aprovações',
description:
'Registe necessidades e acompanhe as decisões de cada departamento.',
icon: ClipboardList,
href: '/consulta/requisicoes',
label: 'Explorar requisições',
step: '01',
},
{
title: 'Fornecedores e compras',
description:
'Compare propostas, fundamente a escolha e acompanhe as ordens de compra.',
icon: Users,
href: '/consulta/fornecedores',
label: 'Explorar fornecedores',
step: '02',
},
{
title: 'Recepção e armazém',
description:
'Confira as entregas, registe a qualidade e consulte as existências aceites.',
icon: PackageCheck,
href: '/consulta/recepcoes',
label: 'Explorar recepção',
step: '03',
},
];
export default function Home() {
return (
<>
<Head title="Gestão de compras" />
<div className="min-h-screen bg-background text-foreground">
<header className="border-b">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-6 py-5 lg:px-10">
<Link href="/" className="flex items-center gap-3">
<span className="grid size-10 place-items-center rounded-xl bg-teal-800 text-lg font-semibold text-white">
SGC
</span>
<div>
<span className="block text-lg font-semibold tracking-tight">
SGC
</span>
<span className="block text-xs text-muted-foreground">
Sistema de Gestão de Compras
</span>
</div>
</Link>
<nav
aria-label="Navegação principal"
className="flex items-center gap-5 text-sm"
>
<Link
href="/"
aria-current="page"
className="hidden font-semibold text-teal-700 sm:block dark:text-teal-400"
>
Início
</Link>
<Link
href="/consulta"
className="text-muted-foreground hover:text-foreground"
>
Demonstração
</Link>
<Button asChild>
<Link href="/login">
Entrar na aplicação{' '}
<ArrowRight className="size-4" />
</Link>
</Button>
</nav>
</div>
</header>
<main className="mx-auto max-w-7xl px-6 lg:px-10">
<section className="grid items-center gap-12 py-16 lg:grid-cols-[1.1fr_1fr] lg:py-24">
<div>
<p className="mb-5 text-xs font-semibold tracking-[0.2em] text-teal-700 uppercase dark:text-teal-400">
Cada etapa no seu lugar
</p>
<h1 className="max-w-2xl text-4xl leading-tight font-semibold tracking-tight sm:text-5xl">
Gestão de compras,
<br />
<span className="text-teal-800 dark:text-teal-400">
do pedido ao stock.
</span>
</h1>
<p className="mt-6 max-w-xl text-base leading-7 text-muted-foreground">
Um percurso organizado para a sua equipa:
necessidades, fornecedores, decisões, entregas e
armazém. Escolha a área em que quer trabalhar ou
conheça primeiro os exemplos de demonstração.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button
asChild
size="lg"
className="bg-teal-800 text-white hover:bg-teal-900"
>
<Link href="/consulta">
Explorar a demonstração{' '}
<ArrowRight className="size-4" />
</Link>
</Button>
<Button asChild size="lg" variant="outline">
<Link href="/login">
Aceder ao espaço de trabalho
</Link>
</Button>
</div>
<p className="mt-5 flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheck className="size-4 shrink-0" />
Demonstração com dados fictícios. O espaço de
trabalho exige autenticação.
</p>
</div>
<div className="rounded-2xl border bg-muted/25 p-6 sm:p-8">
<div className="mb-7 flex items-center justify-between">
<p className="text-sm font-semibold">
O percurso da compra
</p>
<span className="rounded-full bg-teal-100 px-3 py-1 text-xs font-medium text-teal-900 dark:bg-teal-950 dark:text-teal-200">
Por etapas
</span>
</div>
<ol className="space-y-4">
{[
{
title: 'Requisitar e aprovar',
note: 'Necessidade, centro de custo e decisões',
icon: ClipboardList,
},
{
title: 'Seleccionar e emitir',
note: 'Propostas, adjudicação e ordem de compra',
icon: FileCheck2,
},
{
title: 'Receber e conferir',
note: 'Entregas parciais, qualidade e divergências',
icon: PackageCheck,
},
{
title: 'Consultar existências',
note: 'Entradas aceites e histórico por armazém',
icon: Warehouse,
},
].map((step, index) => (
<li
key={step.title}
className="flex items-center gap-4 rounded-xl border bg-background p-4"
>
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-muted text-teal-800 dark:text-teal-400">
<step.icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">
{step.title}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{step.note}
</p>
</div>
<span className="text-xs text-muted-foreground">
0{index + 1}
</span>
</li>
))}
</ol>
</div>
</section>
<section
className="border-t py-12"
aria-labelledby="areas-title"
>
<div className="mb-7 flex flex-wrap items-end justify-between gap-3">
<div>
<p className="mb-2 text-xs font-semibold tracking-widest text-teal-700 uppercase dark:text-teal-400">
Navegação por áreas
</p>
<h2
id="areas-title"
className="text-2xl font-semibold tracking-tight"
>
Por onde quer começar?
</h2>
</div>
<Link
href="/consulta"
className="inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400"
>
Ver todos os módulos{' '}
<ArrowRight className="size-4" />
</Link>
</div>
<div className="grid gap-5 md:grid-cols-3">
{areas.map((area) => (
<Link
href={area.href}
key={area.title}
className="group flex flex-col rounded-xl border bg-card p-6 transition-colors hover:border-teal-600"
>
<div className="mb-8 flex items-center justify-between">
<area.icon className="size-6 text-teal-800 dark:text-teal-400" />
<span className="text-xs text-muted-foreground">
{area.step}
</span>
</div>
<h3 className="font-semibold">
{area.title}
</h3>
<p className="mt-3 flex-1 text-sm leading-6 text-muted-foreground">
{area.description}
</p>
<span className="mt-7 inline-flex items-center gap-2 text-sm font-medium text-teal-700 dark:text-teal-400">
{area.label}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</span>
</Link>
))}
</div>
</section>
<section className="mb-12 flex flex-wrap items-center justify-between gap-5 rounded-xl bg-teal-900 p-6 text-white sm:p-8">
<div>
<h2 className="text-xl font-semibold">
Já faz parte da equipa?
</h2>
<p className="mt-2 max-w-xl text-sm leading-6 text-teal-100">
Entre com a sua conta para aceder aos módulos e
dados autorizados para o seu perfil.
</p>
</div>
<Button asChild variant="secondary">
<Link href="/login">
Entrar no espaço de trabalho{' '}
<ArrowRight className="size-4" />
</Link>
</Button>
</section>
</main>
<footer className="border-t">
<div className="mx-auto flex max-w-7xl flex-wrap justify-between gap-4 px-6 py-6 text-xs leading-5 text-muted-foreground lg:px-10">
<span>SGC · Ambiente de desenvolvimento</span>
<span>
O orçamento ainda não é validado. Os documentos não
autorizam compras reais.
</span>
</div>
</footer>
</div>
</>
);
}
+64 -24
View File
@@ -1,4 +1,4 @@
import { Head, Link } from '@inertiajs/react';
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import {
ArrowRight,
@@ -14,6 +14,8 @@ import {
Search,
ShieldCheck,
Users,
PackageCheck,
Warehouse,
} from 'lucide-react';
import { PageTitle, Panel, Status } from '@/components/procurement';
import { Button } from '@/components/ui/button';
@@ -27,6 +29,10 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { money } from '@/types/procurement';
import { previewPaths } from '@/lib/preview-navigation';
import InventoryDemo from './inventory-demo';
import { OperationsDemo } from '../stock/operations';
import { EquipmentDemo } from '../equipment';
// Static examples only: this page never loads or submits business data.
const requests = [
@@ -108,19 +114,34 @@ const navigation = [
{ id: 'quotes', title: 'Cotações', icon: Scale },
{ id: 'conflicts', title: 'Conflitos de interesses', icon: ShieldCheck },
{ id: 'orders', title: 'Ordens de compra', icon: FileText },
{ id: 'receipts', title: 'Recepção e qualidade', icon: PackageCheck },
{ id: 'stock', title: 'Stock e movimentos', icon: Warehouse },
{ id: 'operations', title: 'Saídas e inventários', icon: Warehouse },
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
];
type Detail = { title: string; subtitle: string; rows: [string, string][] };
export default function Preview() {
const [screen, setScreen] = useState('overview');
const navigationGroups = [
{ title: 'Geral', ids: ['overview'] },
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
{ title: 'Controlo', ids: ['conflicts'] },
{ title: 'Apresentação', ids: ['appearance'] },
];
export default function Preview({
initialScreen = 'overview',
}: {
initialScreen?: string;
}) {
const screen = initialScreen;
const [search, setSearch] = useState('');
const [menu, setMenu] = useState(false);
const [detail, setDetail] = useState<Detail | null>(null);
const title = navigation.find((n) => n.id === screen)?.title ?? 'Consulta';
function navigate(id: string) {
setScreen(id);
setSearch('');
setMenu(false);
router.get(previewPaths[id] ?? '/consulta');
}
const filteredRequests = requests.filter((r) =>
`${r.number} ${r.title} ${r.department}`
@@ -205,13 +226,13 @@ export default function Preview() {
className={`${menu ? 'flex' : 'hidden'} fixed inset-y-0 left-0 z-30 w-64 flex-col border-r bg-background p-5 lg:flex`}
>
<Link
href="/consulta"
href="/"
className="mb-8 flex items-center gap-3 text-xl font-semibold"
>
<span className="grid size-9 place-items-center rounded-xl bg-teal-700 text-sm text-white">
PF
SGC
</span>
ProcuraFlow
SGC
</Link>
<div className="mb-7 rounded-xl border bg-muted/30 p-4">
<p className="flex items-center gap-2 text-sm font-semibold">
@@ -222,20 +243,34 @@ export default function Preview() {
Consulta livre · sem autenticação
</p>
</div>
<nav className="space-y-2">
{navigation.map((n) => (
<button
type="button"
key={n.id}
onClick={() => navigate(n.id)}
aria-current={
screen === n.id ? 'page' : undefined
}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
>
<n.icon className="size-4" />
{n.title}
</button>
<nav
aria-label="Áreas da demonstração"
className="space-y-4"
>
{navigationGroups.map((group) => (
<div key={group.title}>
<p className="mb-1 px-3 text-[10px] font-semibold tracking-widest text-muted-foreground uppercase">
{group.title}
</p>
{navigation
.filter((n) => group.ids.includes(n.id))
.map((n) => (
<Link
href={previewPaths[n.id]}
key={n.id}
onClick={() => setMenu(false)}
aria-current={
screen === n.id
? 'page'
: undefined
}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${screen === n.id ? 'bg-teal-50 font-medium text-teal-800 dark:bg-teal-950 dark:text-teal-200' : 'text-muted-foreground hover:bg-muted'}`}
>
<n.icon className="size-4" />
{n.title}
</Link>
))}
</div>
))}
</nav>
<p className="mt-auto pt-8 text-xs leading-5 text-muted-foreground">
@@ -287,7 +322,7 @@ export default function Preview() {
</span>
</div>
<PageTitle
eyebrow="ProcuraFlow · Demonstração"
eyebrow="SGC · Demonstração"
title={title}
description="Explore os exemplos para verificar a organização, os ecrãs, as cores e a navegação. Os dados apresentados não pertencem à sua empresa."
/>
@@ -621,6 +656,11 @@ export default function Preview() {
</div>
</Panel>
)}
{(screen === 'receipts' || screen === 'stock') && (
<InventoryDemo key={screen} screen={screen} />
)}
{screen === 'operations' && <OperationsDemo />}
{screen === 'equipment' && <EquipmentDemo />}
{screen === 'appearance' && (
<>
<Panel
@@ -708,8 +748,8 @@ export default function Preview() {
</>
)}
<footer className="py-4 text-xs text-muted-foreground">
ProcuraFlow · Consulta pública de exemplos · Os
processos reais permanecem no acesso autenticado.
SGC · Consulta pública de exemplos · Os processos
reais permanecem no acesso autenticado.
</footer>
</main>
</div>
@@ -0,0 +1,344 @@
import { useState } from 'react';
import { InventoryNotice, ReceiptProgressTable } from '@/components/inventory';
import { Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { quantity } from '@/types/inventory';
import type { ReceiptProgress } from '@/types/inventory';
const progress: ReceiptProgress = {
state: 'partial',
has_divergences: true,
lines: [
{
id: 1,
description: 'Papel A4 de demonstração',
unit: 'cx',
purchase_type: 'consumable',
ordered_milli: 10000,
received_milli: 6000,
accepted_milli: 5000,
rejected_milli: 1000,
delivery_pending_milli: 4000,
acceptance_pending_milli: 5000,
},
{
id: 2,
description: 'Tinteiro de demonstração',
unit: 'un',
purchase_type: 'consumable',
ordered_milli: 3000,
received_milli: 3000,
accepted_milli: 3000,
rejected_milli: 0,
delivery_pending_milli: 0,
acceptance_pending_milli: 0,
},
],
};
const examples = [
{
code: 'DEMO-A4',
name: 'Papel A4 de demonstração',
unit: 'cx',
quantity_milli: 5000,
warehouse: 'Armazém de demonstração',
receipt: 'REC-DEMO-001',
note: 'Uma caixa rejeitada por danos; só cinco caixas aceites entraram em stock.',
},
{
code: 'DEMO-TINTA',
name: 'Tinteiro de demonstração',
unit: 'un',
quantity_milli: 3000,
warehouse: 'Armazém de demonstração',
receipt: 'REC-DEMO-001',
note: 'Três unidades recebidas e aceites integralmente.',
},
];
export default function InventoryDemo({
screen,
}: {
screen: 'receipts' | 'stock';
}) {
const [search, setSearch] = useState('');
const [detail, setDetail] = useState<(typeof examples)[number] | null>(
null,
);
const [quality, setQuality] = useState('reservations');
const [notice, setNotice] = useState(false);
const visible = examples.filter((example) =>
`${example.code} ${example.name}`
.toLowerCase()
.includes(search.toLowerCase()),
);
return (
<>
<InventoryNotice />
{screen === 'receipts' ? (
<>
<Panel
title="OC-DEMO-001 · Recepção parcial"
description="Exemplo fictício: seis caixas conferidas, cinco aceites e uma rejeitada. Existem quatro caixas por entregar."
>
<ReceiptProgressTable progress={progress} />
<p className="mt-4 text-sm text-muted-foreground">
Emitente: Comprador de exemplo · Recepção: Armazém
de exemplo. São pessoas distintas.
</p>
</Panel>
<Panel
title="Conferência de qualidade"
description="Experimente os campos. Este formulário é local, não é enviado e não confirma recepções reais."
>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
setNotice(true);
}}
>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="demo-guide">
Guia fictícia
</Label>
<Input
id="demo-guide"
defaultValue="GR-DEMO-001"
/>
</div>
<div className="space-y-2">
<Label htmlFor="demo-received">
Quantidade recebida
</Label>
<Input
id="demo-received"
type="number"
min="0.001"
step="0.001"
defaultValue="6"
/>
</div>
<div className="space-y-2">
<Label htmlFor="demo-quality">
Qualidade
</Label>
<select
id="demo-quality"
className={controlClass}
value={quality}
onChange={(event) =>
setQuality(event.target.value)
}
>
<option value="compliant">
Conforme
</option>
<option value="reservations">
Conforme com reservas
</option>
<option value="nonconforming">
Não conforme
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="demo-accepted">
Quantidade aceite
</Label>
<Input
id="demo-accepted"
type="number"
min="0"
step="0.001"
defaultValue="5"
/>
</div>
<div className="space-y-2">
<Label htmlFor="demo-location">
Destino fictício
</Label>
<select
id="demo-location"
className={controlClass}
>
<option>Armazém de demonstração</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="demo-article">
Artigo fictício
</Label>
<select
id="demo-article"
className={controlClass}
>
<option>
DEMO-A4 · Papel A4 de demonstração
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="demo-divergence">
Divergências
</Label>
<textarea
id="demo-divergence"
className={`${controlClass} h-24 py-3`}
defaultValue="Uma caixa com danos na embalagem e humidade. Rejeitada e sem entrada em stock."
/>
</div>
<Button>Pré-visualizar conferência fictícia</Button>
{notice && (
<p
role="status"
className="text-sm text-teal-700 dark:text-teal-400"
>
Demonstração local: nenhum dado foi
guardado. No módulo autenticado, o servidor
verifica quantidades, segregação e
duplicações.
</p>
)}
</form>
</Panel>
</>
) : (
<>
<Panel
title="Existências fictícias"
description="Somente quantidades aceites. Pesquise e abra os detalhes para rever a apresentação."
>
<Input
aria-label="Pesquisar artigos fictícios"
placeholder="Pesquisar código ou artigo de demonstração"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="mb-5 max-w-sm"
/>
<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 className="pr-4">Em stock</th>
<th>Histórico</th>
</tr>
</thead>
<tbody>
{visible.map((example) => (
<tr
className="border-b last:border-0"
key={example.code}
>
<td className="py-4 pr-4">
<strong>{example.code}</strong>
<p className="mt-1 text-muted-foreground">
{example.name}
</p>
</td>
<td className="pr-4">
{example.warehouse}
</td>
<td className="pr-4">
{quantity(
example.quantity_milli,
)}{' '}
{example.unit}
</td>
<td>
<Button
variant="outline"
onClick={() =>
setDetail(example)
}
>
Ver movimentos
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{visible.length === 0 && (
<p className="py-5 text-sm text-muted-foreground">
Nenhum exemplo corresponde à pesquisa.
</p>
)}
</Panel>
<Panel title="Histórico de entradas fictícias">
<div className="space-y-3">
{visible.map((example) => (
<button
type="button"
onClick={() => setDetail(example)}
key={example.code}
className="w-full rounded-lg border p-4 text-left text-sm"
>
<strong>
+{quantity(example.quantity_milli)}{' '}
{example.unit} · {example.code}
</strong>
<p className="mt-1 text-muted-foreground">
{example.receipt} · OC-DEMO-001 · Guia
GR-DEMO-001
</p>
</button>
))}
</div>
<p className="mt-4 text-xs text-muted-foreground">
Os exemplos não correspondem a existências da
empresa. Consulte Saídas e inventários para rever os
novos exemplos fictícios.
</p>
</Panel>
</>
)}
<Dialog
open={detail !== null}
onOpenChange={(open) => {
if (!open) setDetail(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{detail?.code} · Entrada fictícia
</DialogTitle>
<DialogDescription>
Histórico ilustrativo, sem acesso aos registos
reais.
</DialogDescription>
</DialogHeader>
{detail && (
<div className="space-y-3 text-sm">
<p>
{detail.name} · +
{quantity(detail.quantity_milli)} {detail.unit}
</p>
<p>{detail.receipt} · OC-DEMO-001 · GR-DEMO-001</p>
<p>{detail.note}</p>
<p>
Confirmado por Armazém de exemplo, distinto do
emitente.
</p>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
+6
View File
@@ -28,6 +28,12 @@ export default function OrderShow({
return (
<>
<Head title={order.number} />
<Link
href={`/ordens/${order.id}/recepcoes`}
className="mx-auto mt-4 w-full max-w-6xl px-5 text-sm font-medium text-teal-700 dark:text-teal-400"
>
Recepções, qualidade e quantidades pendentes
</Link>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={snapshot.company.name}
+94
View File
@@ -0,0 +1,94 @@
import { Head, Link } from '@inertiajs/react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import { PageTitle, Panel } from '@/components/procurement';
import { date } from '@/types/procurement';
import { receiptStates } from '@/types/inventory';
import type { Page, Receipt, ReceiptProgress } from '@/types/inventory';
type IncomingOrder = {
id: number;
number: string;
supplier_name: string;
progress: ReceiptProgress;
};
export default function Receipts({
orders,
receipts,
}: {
orders: Page<IncomingOrder>;
receipts: Page<Receipt>;
}) {
return (
<>
<Head title="Recepção de bens" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow="Conferência e armazém"
title="Recepção de bens e serviços"
description="Recepções parciais e totais, conferência de qualidade e quantidades pendentes por ordem emitida."
/>
<InventoryNotice />
<Panel title="Ordens para conferência">
<div className="divide-y">
{orders.data.map((order) => (
<Link
key={order.id}
href={`/ordens/${order.id}/recepcoes`}
className="flex flex-wrap items-center justify-between gap-3 py-4 text-sm"
>
<div>
<strong>{order.number}</strong>
<p className="mt-1 text-muted-foreground">
{order.supplier_name}
</p>
</div>
<span>
{receiptStates[order.progress.state]}
</span>
</Link>
))}
</div>
{orders.data.length === 0 && (
<p className="py-6 text-sm text-muted-foreground">
Ainda não existem ordens emitidas disponíveis para o
seu perfil.
</p>
)}
<InventoryPagination page={orders} />
</Panel>
<Panel title="Histórico de confirmações">
<div className="divide-y">
{receipts.data.map((receipt) => (
<Link
key={receipt.id}
href={`/recepcoes/${receipt.id}`}
className="flex flex-wrap justify-between gap-3 py-4 text-sm"
>
<div>
<strong>{receipt.number}</strong> ·{' '}
{receipt.snapshot.order.number}
<p className="mt-1 text-muted-foreground">
Guia {receipt.delivery_note} ·{' '}
{receipt.snapshot.receiver.name}
</p>
</div>
<span>
{date(receipt.received_on)} ·{' '}
{receiptStates[receipt.status]}
</span>
</Link>
))}
</div>
{receipts.data.length === 0 && (
<p className="py-6 text-sm text-muted-foreground">
As recepções confirmadas ficam preservadas neste
histórico.
</p>
)}
<InventoryPagination page={receipts} />
</Panel>
</div>
</>
);
}
Receipts.layout = { breadcrumbs: [{ title: 'Recepção', href: '/recepcoes' }] };
+562
View File
@@ -0,0 +1,562 @@
import { Head, Link, useForm } from '@inertiajs/react';
import { InventoryNotice, ReceiptProgressTable } 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 { quantity, qualityStates, receiptStates } from '@/types/inventory';
import type {
Receipt,
ReceiptProgress,
StockItem,
Warehouse,
} from '@/types/inventory';
type Line = {
include: boolean;
purchase_order_item_id: number;
received_quantity: string;
accepted_quantity: string;
quality_status: string;
notes: string;
stock_item_id: string;
};
type Props = {
order: { id: number; number: string };
supplier_name: string;
progress: ReceiptProgress;
receipts: Receipt[];
warehouses: Warehouse[];
stockItems: StockItem[];
canConfirm: boolean;
};
export default function ReceiveOrder({
order,
supplier_name,
progress,
receipts,
warehouses,
stockItems,
canConfirm,
}: Props) {
const form = useForm({
idempotency_key: crypto.randomUUID(),
delivery_note: '',
received_on: '',
warehouse_id: '',
items: progress.lines.map((line) => ({
include: false,
purchase_order_item_id: line.id,
received_quantity: '',
accepted_quantity: '',
quality_status: 'compliant',
notes: '',
stock_item_id: '',
})),
});
const change = (index: number, updates: Partial<Line>) =>
form.setData(
'items',
form.data.items.map((line, i) =>
i === index ? { ...line, ...updates } : line,
),
);
const outstanding = progress.lines.some(
(line) => line.delivery_pending_milli > 0,
);
return (
<>
<Head title={`Receber ${order.number}`} />
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={order.number}
title="Conferir entrega"
description={supplier_name}
/>
<InventoryNotice />
<Panel title="Quantidades da ordem">
<ReceiptProgressTable progress={progress} />
<p className="mt-4 text-xs text-muted-foreground">
As quantidades rejeitadas contam como conferidas, mas
não entram em stock. Substituições ou reposições após
rejeição não são autorizadas automaticamente.
</p>
</Panel>
{canConfirm && outstanding ? (
<Panel
title="Confirmar recepção"
description="A confirmação é definitiva e auditada. O emitente da ordem não pode confirmar a recepção."
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
warehouse_id: data.warehouse_id || null,
items: data.items
.filter((line) => line.include)
.map(
({
include: _include,
...line
}) => ({
...line,
stock_item_id:
line.stock_item_id || null,
}),
),
}));
form.post(`/ordens/${order.id}/recepcoes`);
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="receipt-note">
Guia de remessa
</Label>
<Input
id="receipt-note"
required
maxLength={100}
value={form.data.delivery_note}
onChange={(e) =>
form.setData(
'delivery_note',
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="receipt-date">
Data da recepção
</Label>
<Input
id="receipt-date"
type="date"
required
value={form.data.received_on}
onChange={(e) =>
form.setData(
'received_on',
e.target.value,
)
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="receipt-warehouse">
Armazém (para bens aceites)
</Label>
<select
id="receipt-warehouse"
className={controlClass}
value={form.data.warehouse_id}
onChange={(e) =>
form.setData(
'warehouse_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{warehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.code} · {w.name}
</option>
))}
</select>
</div>
</div>
<div className="space-y-5">
{progress.lines.map(
(source, index) =>
source.delivery_pending_milli > 0 && (
<fieldset
className="rounded-xl border p-4"
key={source.id}
>
<legend className="px-2 text-sm font-semibold">
{source.description} · Por
entregar{' '}
{quantity(
source.delivery_pending_milli,
)}{' '}
{source.unit}
</legend>
<label className="mb-4 flex items-center gap-3 text-sm">
<input
type="checkbox"
checked={
form.data.items[
index
].include
}
onChange={(e) =>
change(index, {
include:
e.target
.checked,
})
}
/>
Incluir nesta recepção
</label>
{form.data.items[index]
.include && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor={`received-${source.id}`}
>
Quantidade
recebida
</Label>
<Input
id={`received-${source.id}`}
type="number"
required
min="0.001"
step="0.001"
max={
source.delivery_pending_milli /
1000
}
value={
form.data
.items[
index
]
.received_quantity
}
onChange={(e) =>
change(
index,
{
received_quantity:
e
.target
.value,
...(form
.data
.items[
index
]
.quality_status ===
'compliant'
? {
accepted_quantity:
e
.target
.value,
}
: {}),
},
)
}
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`quality-${source.id}`}
>
Qualidade
</Label>
<select
id={`quality-${source.id}`}
className={
controlClass
}
value={
form.data
.items[
index
]
.quality_status
}
onChange={(e) =>
change(
index,
{
quality_status:
e
.target
.value,
...(e
.target
.value ===
'nonconforming'
? {
accepted_quantity:
'0',
stock_item_id:
'',
}
: e
.target
.value ===
'compliant'
? {
accepted_quantity:
form
.data
.items[
index
]
.received_quantity,
}
: {}),
},
)
}
>
{Object.entries(
qualityStates,
).map(
([
value,
label,
]) => (
<option
key={
value
}
value={
value
}
>
{
label
}
</option>
),
)}
</select>
</div>
<div className="space-y-2">
<Label
htmlFor={`accepted-${source.id}`}
>
Quantidade
aceite
</Label>
<Input
id={`accepted-${source.id}`}
type="number"
required
min="0"
step="0.001"
readOnly={
form.data
.items[
index
]
.quality_status !==
'reservations'
}
value={
form.data
.items[
index
]
.accepted_quantity
}
onChange={(e) =>
change(
index,
{
accepted_quantity:
e
.target
.value,
},
)
}
/>
</div>
{source.purchase_type !==
'service' ? (
<div className="space-y-2">
<Label
htmlFor={`stock-${source.id}`}
>
Artigo de
stock ·
unidade{' '}
{
source.unit
}
</Label>
<select
id={`stock-${source.id}`}
className={
controlClass
}
value={
form
.data
.items[
index
]
.stock_item_id
}
onChange={(
e,
) =>
change(
index,
{
stock_item_id:
e
.target
.value,
},
)
}
>
<option value="">
Seleccione
para a
quantidade
aceite
</option>
{stockItems
.filter(
(
item,
) =>
item.unit.toLowerCase() ===
source.unit.toLowerCase(),
)
.map(
(
item,
) => (
<option
key={
item.id
}
value={
item.id
}
>
{
item.code
}{' '}
·{' '}
{
item.name
}
</option>
),
)}
</select>
</div>
) : (
<p className="self-center text-sm text-muted-foreground">
Serviço: a
aceitação não
cria existências
físicas.
</p>
)}
<div className="space-y-2 md:col-span-2">
<Label
htmlFor={`notes-${source.id}`}
>
Observações e
divergências
</Label>
<textarea
id={`notes-${source.id}`}
className={`${controlClass} h-24 py-3`}
required={
form.data
.items[
index
]
.quality_status !==
'compliant'
}
maxLength={3000}
value={
form.data
.items[
index
].notes
}
onChange={(e) =>
change(
index,
{
notes: e
.target
.value,
},
)
}
/>
</div>
</div>
)}
</fieldset>
),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure previamente os armazéns e artigos em
Stock. Não existem conversões automáticas de
unidade, tolerâncias de excesso ou saldo inicial
manual.
</p>
<Button disabled={form.processing}>
Confirmar recepção e entradas aceites
</Button>
</form>
</Panel>
) : (
<p className="text-sm text-muted-foreground">
{outstanding
? 'A confirmação exige o perfil Recepção / Armazém e uma pessoa distinta do emitente.'
: 'Não existem quantidades de entrega pendentes nesta ordem.'}
</p>
)}
<Panel title="Recepções anteriores">
<div className="divide-y">
{receipts.map((receipt) => (
<Link
key={receipt.id}
href={`/recepcoes/${receipt.id}`}
className="flex flex-wrap justify-between gap-3 py-3 text-sm"
>
<span>
{receipt.number} · Guia{' '}
{receipt.delivery_note}
</span>
<span>{receiptStates[receipt.status]}</span>
</Link>
))}
</div>
{receipts.length === 0 && (
<p className="text-sm text-muted-foreground">
Ainda não há recepções confirmadas.
</p>
)}
</Panel>
</div>
</>
);
}
ReceiveOrder.layout = {
breadcrumbs: [
{ title: 'Recepção', href: '/recepcoes' },
{ title: 'Conferir ordem', href: '#' },
],
};
+123
View File
@@ -0,0 +1,123 @@
import { Head, Link } from '@inertiajs/react';
import { InventoryNotice } from '@/components/inventory';
import { PageTitle, Panel } from '@/components/procurement';
import { date } from '@/types/procurement';
import { quantity, qualityStates, receiptStates } from '@/types/inventory';
import type { Receipt, StockMovement } from '@/types/inventory';
export default function ReceiptShow({
receipt,
movements,
}: {
receipt: Receipt;
movements: StockMovement[];
}) {
return (
<>
<Head title={receipt.number} />
<div className="mx-auto flex w-full max-w-6xl flex-col gap-7 p-5 lg:p-8">
<PageTitle
eyebrow={receipt.snapshot.order.number}
title={receipt.number}
description={`${receipt.snapshot.supplier.name} · ${receiptStates[receipt.status]}`}
/>
<InventoryNotice />
<Panel title="Confirmação preservada">
<dl className="grid gap-5 text-sm sm:grid-cols-3">
<div>
<dt className="text-muted-foreground">
Guia de remessa
</dt>
<dd>{receipt.delivery_note}</dd>
</div>
<div>
<dt className="text-muted-foreground">
Recebido em / por
</dt>
<dd>
{date(receipt.received_on)} ·{' '}
{receipt.snapshot.receiver.name}
</dd>
</div>
<div>
<dt className="text-muted-foreground">Armazém</dt>
<dd>
{receipt.snapshot.warehouse?.name ??
'Sem entrada física'}
</dd>
</div>
</dl>
</Panel>
<Panel title="Quantidade, qualidade e divergências">
<div className="space-y-5">
{receipt.items.map((line) => (
<div
className="rounded-lg border p-4 text-sm"
key={line.id}
>
<h3 className="font-semibold">
{line.snapshot.order_item.description} ·{' '}
{qualityStates[line.quality_status]}
</h3>
<p className="mt-2">
Recebido: {quantity(line.received_milli)} ·
Aceite: {quantity(line.accepted_milli)} ·
Rejeitado: {quantity(line.rejected_milli)}{' '}
{line.snapshot.order_item.unit}
</p>
<p className="mt-2 text-muted-foreground">
{line.snapshot.stock_item
? `${line.snapshot.stock_item.code} · ${line.snapshot.stock_item.name}`
: line.snapshot.physical
? 'Sem quantidade aceite em stock.'
: 'Serviço, sem movimento físico.'}
</p>
{line.notes && (
<p className="mt-3 whitespace-pre-wrap">
{line.notes}
</p>
)}
</div>
))}
</div>
</Panel>
<Panel title="Entradas ligadas a esta recepção">
<div className="space-y-3">
{movements.map((movement) => (
<p className="text-sm" key={movement.id}>
Movimento #{movement.id} · +
{quantity(movement.quantity_milli)}{' '}
{movement.snapshot.stock_item.unit} ·{' '}
{movement.snapshot.stock_item.code} ·{' '}
{movement.snapshot.warehouse.name}
</p>
))}
{movements.length === 0 && (
<p className="text-sm text-muted-foreground">
Não houve entradas de bens aceites nesta
confirmação.
</p>
)}
</div>
<p className="mt-4 text-xs text-muted-foreground">
A confirmação e os movimentos não podem ser editados ou
eliminados. Repetir a mesma confirmação não cria novas
entradas.
</p>
</Panel>
<Link
href={`/ordens/${receipt.purchase_order_id}/recepcoes`}
className="text-sm text-teal-700 dark:text-teal-400"
>
Consultar a ordem e as quantidades pendentes
</Link>
</div>
</>
);
}
ReceiptShow.layout = {
breadcrumbs: [
{ title: 'Recepção', href: '/recepcoes' },
{ title: 'Confirmação', href: '#' },
],
};
+180
View File
@@ -21,6 +21,10 @@ type Line = {
unit: string;
unit_price: string;
tax_rate: string;
equipment_id: string;
spare_part_id: string;
production_area: string;
downtime_started_at: string;
};
const emptyLine = (): Line => ({
key: crypto.randomUUID(),
@@ -31,6 +35,10 @@ const emptyLine = (): Line => ({
unit: 'un',
unit_price: '',
tax_rate: '0',
equipment_id: '',
spare_part_id: '',
production_area: '',
downtime_started_at: '',
});
const lineFields = [
{ key: 'description', label: 'Descrição', type: 'text', maxLength: 255 },
@@ -65,9 +73,15 @@ const lineFields = [
export default function RequisitionForm({
costCenters,
requisition,
equipment = [],
spareParts = [],
stockItems = [],
}: {
costCenters: CostCenter[];
requisition: Requisition | null;
equipment?: { id: number; code: string; description: string }[];
spareParts?: { id: number; stock_item_id: number }[];
stockItems?: { id: number; code: string; name: string; unit: string }[];
}) {
const form = useForm({
cost_center_id: requisition ? String(requisition.cost_center_id) : '',
@@ -83,6 +97,10 @@ export default function RequisitionForm({
unit: i.unit,
unit_price: (i.unit_price_minor / 100).toFixed(2),
tax_rate: String(i.tax_rate),
equipment_id: i.equipment_id ? String(i.equipment_id) : '',
spare_part_id: i.spare_part_id ? String(i.spare_part_id) : '',
production_area: i.production_area ?? '',
downtime_started_at: i.downtime_started_at?.slice(0, 16) ?? '',
})) ?? [emptyLine()],
});
const changeLine = (index: number, field: keyof Line, value: string) =>
@@ -276,6 +294,14 @@ export default function RequisitionForm({
)
}
>
<option value="equipment">
Equipamento (emissão
bloqueada até definição da
regra de investimento)
</option>
<option value="spare_part">
Peça sobressalente
</option>
<option value="consumable">
Insumos / matéria-prima
</option>
@@ -286,6 +312,160 @@ export default function RequisitionForm({
Outro
</option>
</select>
{line.purchase_type ===
'equipment' && (
<label className="mt-3 grid gap-2 text-sm">
Área / linha de produção
<Input
required
value={
line.production_area
}
onChange={(e) =>
changeLine(
index,
'production_area',
e.target.value,
)
}
/>
</label>
)}
{line.purchase_type ===
'spare_part' && (
<div className="mt-3 grid gap-3">
<label className="grid gap-2 text-sm">
Equipamento do
departamento
<select
required
className={
controlClass
}
value={
line.equipment_id
}
onChange={(e) =>
changeLine(
index,
'equipment_id',
e.target
.value,
)
}
>
<option value="">
Seleccionar
</option>
{equipment.map(
(a) => (
<option
key={
a.id
}
value={
a.id
}
>
{a.code}{' '}
·{' '}
{
a.description
}
</option>
),
)}
</select>
</label>
<label className="grid gap-2 text-sm">
Peça cadastrada
<select
required
className={
controlClass
}
value={
line.spare_part_id
}
onChange={(e) =>
changeLine(
index,
'spare_part_id',
e.target
.value,
)
}
>
<option value="">
Seleccionar
</option>
{spareParts.map(
(p) => (
<option
key={
p.id
}
value={
p.id
}
>
{
stockItems.find(
(
s,
) =>
s.id ===
p.stock_item_id,
)
?.code
}{' '}
·{' '}
{
stockItems.find(
(
s,
) =>
s.id ===
p.stock_item_id,
)
?.name
}
</option>
),
)}
</select>
</label>
<label className="grid gap-2 text-sm">
Início da paragem
(obrigatório para peça
crítica urgente; hora
UTC)
<Input
type="datetime-local"
value={
line.downtime_started_at
}
onChange={(e) =>
changeLine(
index,
'downtime_started_at',
e.target
.value,
)
}
/>
</label>
<p className="text-xs text-muted-foreground">
Use a unidade do artigo.
A associação
peça/equipamento é
revalidada no servidor.
Urgência não dispensa
três cotações ou
aprovações.
</p>
</div>
)}
</div>
</div>
<div className="mt-3 flex justify-end">
+113
View File
@@ -32,14 +32,31 @@ export default function Show({
canDecide,
conflictBlocked,
audit,
technical,
}: {
requisition: Requisition;
canEdit: boolean;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
technical?: {
canReview: boolean;
basis_hash: string;
reviews: {
id: number;
decision: string;
reason: string;
basis_hash: string;
snapshot: { reviewer: { name: string } };
}[];
};
}) {
const submission = useForm({});
const review = useForm({
basis_hash: technical?.basis_hash ?? '',
decision: 'approved',
reason: '',
});
const decision = useForm<{ decision: string; comment: string }>({
decision: 'approved',
comment: '',
@@ -55,6 +72,86 @@ export default function Show({
action={<Status value={r.status} />}
/>
<FormErrors errors={submission.errors} />
{r.items.some((i) => i.purchase_type === 'equipment') && (
<Panel title="Validação técnica do equipamento">
<p className="mb-4 text-sm">
Responsável da área, distinto do solicitante.
Finanças é obrigatória para qualquer valor. A
adjudicação e a emissão permanecem bloqueadas
enquanto o limite próprio de investimento e a sua
regra de aplicação não forem definidos. O orçamento
ainda não é validado; documentos de desenvolvimento
não autorizam compras reais.
</p>
{technical?.reviews.map((t) => (
<p
key={t.id}
className="mb-3 rounded-lg border p-3 text-sm"
>
{t.decision === 'approved'
? 'Validado'
: 'Rejeitado'}{' '}
por {t.snapshot.reviewer.name} ·{' '}
{t.basis_hash === technical.basis_hash
? 'Versão actual'
: 'Versão anterior, decisão preservada'}{' '}
· {t.reason}
</p>
))}
{technical?.canReview && (
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
review.transform((d) => ({
...d,
basis_hash: technical.basis_hash,
}));
review.post(
`/requisicoes/${r.id}/validacao-tecnica`,
);
}}
>
<select
aria-label="Decisão técnica"
className={controlClass}
value={review.data.decision}
onChange={(e) =>
review.setData(
'decision',
e.target.value,
)
}
>
<option value="approved">
Validar tecnicamente
</option>
<option value="rejected">
Rejeitar tecnicamente
</option>
</select>
<label className="grid gap-2 text-sm">
Fundamento técnico
<textarea
required
className={controlClass}
value={review.data.reason}
onChange={(e) =>
review.setData(
'reason',
e.target.value,
)
}
/>
</label>
<FormErrors errors={review.errors} />
<Button disabled={review.processing}>
Registar decisão técnica
</Button>
</form>
)}
</Panel>
)}
{conflictBlocked && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
Existe um conflito de interesses activo que impede a
@@ -161,6 +258,22 @@ export default function Show({
<p className="mt-1 text-xs text-muted-foreground">
{i.category}
</p>
{i.equipment_id && (
<p className="mt-1 text-xs">
Equipamento #
{i.equipment_id} ·
Peça #
{i.spare_part_id}
{i.downtime_started_at &&
` · Paragem: ${i.downtime_started_at} UTC`}
</p>
)}
{i.production_area && (
<p className="mt-1 text-xs">
Área de destino:{' '}
{i.production_area}
</p>
)}
</td>
<td className="pr-3 whitespace-nowrap">
{Number(i.quantity)}{' '}
+312
View File
@@ -0,0 +1,312 @@
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' }],
};
+541
View File
@@ -0,0 +1,541 @@
import { Head, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import {
PageTitle,
Panel,
FormErrors,
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { quantity } from '@/types/inventory';
import type { StockItem, Warehouse, Page } from '@/types/inventory';
type Operation = {
id: number;
kind: string;
reference: string;
reason: string;
destination?: string;
quantity_milli: number;
theoretical_milli: number;
stock_item_id: number;
warehouse_id: number;
recorded_at: string;
snapshot: {
actor: { name: string };
responsible: { name: string };
stock_item: { name: string; unit: string };
warehouse: { name: string };
destination_warehouse?: { name: string } | null;
equipment?: { code: string; description: string } | null;
};
};
type Props = {
items: StockItem[];
warehouses: Warehouse[];
users: { id: number; name: string }[];
balances: {
stock_item_id: number;
warehouse_id: number;
quantity: number;
version: number;
}[];
operations: Page<Operation>;
counts: Operation[];
canOperate: boolean;
demo?: boolean;
equipment?: { id: number; code: string; description: string }[];
};
const kinds: Record<string, string> = {
issue: 'Saída',
transfer: 'Transferência',
count: 'Contagem física',
adjustment: 'Ajuste de inventário',
};
export default function Operations({
items,
warehouses,
users,
balances,
operations,
counts,
canOperate,
demo = false,
equipment = [],
}: Props) {
const [message, setMessage] = useState('');
const form = useForm({
kind: 'issue',
reference: '',
idempotency_key: crypto.randomUUID(),
stock_item_id: String(items[0]?.id ?? ''),
warehouse_id: String(warehouses[0]?.id ?? ''),
destination_warehouse_id: '',
responsible_id: '',
destination: '',
reason: '',
quantity: '',
count_id: '',
ledger_version: 0,
equipment_id: '',
});
const balance = balances.find(
(b) =>
String(b.stock_item_id) === form.data.stock_item_id &&
String(b.warehouse_id) === form.data.warehouse_id,
);
return (
<>
<Head title="Operações de stock" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 p-5 lg:p-8">
<PageTitle
eyebrow="Armazém"
title="Saídas, transferências e inventários"
description="Movimentos com fundamento, contagens físicas e histórico preservado."
/>
<InventoryNotice />
{canOperate && form.data.kind === 'issue' && (
<label className="grid gap-2 text-sm">
Equipamento de destino (obrigatório para peças
associadas)
<select
className={controlClass}
value={form.data.equipment_id}
onChange={(e) =>
form.setData('equipment_id', e.target.value)
}
>
<option value="">Sem equipamento</option>
{equipment.map((e) => (
<option key={e.id} value={e.id}>
{e.code} · {e.description}
</option>
))}
</select>
</label>
)}
{demo && (
<p className="rounded-lg border bg-teal-50 p-4 text-sm text-teal-900">
Demonstração pública com dados fictícios. O formulário
apenas simula a revisão.
</p>
)}
<Panel
title="Registar operação"
description="Só Recepção / Armazém executa movimentos. Administração, Procurement e Finanças consultam. Periodicidade, tolerâncias e investigação de divergências aguardam decisão da empresa."
>
{canOperate ? (
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
if (demo) {
setMessage(
'Simulação revista. Nenhum movimento real foi criado.',
);
return;
}
form.transform((d) => ({
...d,
ledger_version: balance?.version ?? 0,
}));
form.post('/stock/operacoes', {
onSuccess: () => {
form.setData({
...form.data,
idempotency_key:
crypto.randomUUID(),
reference: '',
quantity: '',
reason: '',
});
},
});
}}
>
<label className="grid gap-2 text-sm">
Operação
<select
className={controlClass}
value={form.data.kind}
onChange={(e) =>
form.setData('kind', e.target.value)
}
>
{Object.entries(kinds).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
Referência única do documento
<Input
required
value={form.data.reference}
onChange={(e) =>
form.setData(
'reference',
e.target.value,
)
}
/>
</label>
<label className="grid gap-2 text-sm">
Artigo
<select
className={controlClass}
value={form.data.stock_item_id}
onChange={(e) =>
form.setData(
'stock_item_id',
e.target.value,
)
}
>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.code} · {i.name} ({i.unit})
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm">
Armazém de origem / contagem
<select
className={controlClass}
value={form.data.warehouse_id}
onChange={(e) =>
form.setData(
'warehouse_id',
e.target.value,
)
}
>
{warehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.code} · {w.name}
</option>
))}
</select>
</label>
<p className="text-sm font-semibold sm:col-span-2">
Stock teórico:{' '}
{quantity(balance?.quantity ?? 0)} · versão{' '}
{balance?.version ?? 0}. Confirme a contagem
física sobre este saldo; movimentos posteriores
exigem nova contagem.
</p>
{form.data.kind === 'transfer' && (
<label className="grid gap-2 text-sm">
Armazém de destino
<select
required
className={controlClass}
value={
form.data.destination_warehouse_id
}
onChange={(e) =>
form.setData(
'destination_warehouse_id',
e.target.value,
)
}
>
<option value="">Seleccionar</option>
{warehouses
.filter(
(w) =>
String(w.id) !==
form.data.warehouse_id,
)
.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
</label>
)}
{form.data.kind === 'issue' && (
<label className="grid gap-2 text-sm">
Destino (departamento, requisição ou
utilização)
<Input
required
value={form.data.destination}
onChange={(e) =>
form.setData(
'destination',
e.target.value,
)
}
/>
</label>
)}
{form.data.kind === 'adjustment' ? (
<label className="grid gap-2 text-sm">
Contagem a ajustar
<select
required
className={controlClass}
value={form.data.count_id}
onChange={(e) =>
form.setData(
'count_id',
e.target.value,
)
}
>
<option value="">
Seleccionar contagem
</option>
{counts
.filter(
(c) =>
String(c.warehouse_id) ===
form.data
.warehouse_id &&
String(c.stock_item_id) ===
form.data.stock_item_id,
)
.map((c) => (
<option key={c.id} value={c.id}>
{c.reference}: contado{' '}
{quantity(c.quantity_milli)}
, teórico{' '}
{quantity(
c.theoretical_milli,
)}
</option>
))}
</select>
</label>
) : (
<label className="grid gap-2 text-sm">
{form.data.kind === 'count'
? 'Quantidade física contada (inclui zero)'
: 'Quantidade'}
<Input
required
type="number"
min={
form.data.kind === 'count'
? 0
: 0.001
}
step="0.001"
value={form.data.quantity}
onChange={(e) =>
form.setData(
'quantity',
e.target.value,
)
}
/>
</label>
)}
<label className="grid gap-2 text-sm">
Responsável
<select
required
className={controlClass}
value={form.data.responsible_id}
onChange={(e) =>
form.setData(
'responsible_id',
e.target.value,
)
}
>
<option value="">Seleccionar</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm sm:col-span-2">
Fundamento / explicação da divergência
<textarea
required
className={controlClass}
value={form.data.reason}
onChange={(e) =>
form.setData('reason', e.target.value)
}
/>
</label>
<div className="sm:col-span-2">
<FormErrors errors={form.errors} />
<Button disabled={form.processing}>
{demo
? 'Rever simulação'
: 'Confirmar operação'}
</Button>
<p role="status" className="mt-3 text-sm">
{message}
</p>
</div>
</form>
) : (
<p className="text-sm">
Consulta do histórico. O seu perfil não executa
operações de armazém.
</p>
)}
</Panel>
<Panel
title="Histórico auditado"
description="Contagens não alteram o saldo. Ajustes usam a diferença exacta de uma contagem válida; transferências registam saída e entrada na mesma transacção."
>
<div className="space-y-4">
{operations.data.map((o) => (
<article
key={o.id}
className="rounded-lg border p-4 text-sm"
>
<div className="flex flex-wrap justify-between gap-2">
<strong>
{o.reference} · {kinds[o.kind]}
</strong>
<time>
{new Date(o.recorded_at).toLocaleString(
'pt-MZ',
)}
</time>
</div>
<p className="mt-2">
{o.snapshot.stock_item.name} ·{' '}
{quantity(o.quantity_milli)}{' '}
{o.snapshot.stock_item.unit} ·{' '}
{o.snapshot.warehouse.name}
{o.snapshot.destination_warehouse &&
` → ${o.snapshot.destination_warehouse.name}`}
{o.destination && ` → ${o.destination}`}
</p>
{(o.kind === 'count' ||
o.kind === 'adjustment') && (
<p>
Teórico: {quantity(o.theoretical_milli)}{' '}
· Contado: {quantity(o.quantity_milli)}{' '}
· Divergência:{' '}
{quantity(
o.quantity_milli -
o.theoretical_milli,
)}
</p>
)}
<p className="mt-2">{o.reason}</p>
{o.snapshot.equipment && (
<p className="mt-2">
Equipamento no momento da saída:{' '}
{o.snapshot.equipment.code} ·{' '}
{o.snapshot.equipment.description}
</p>
)}
<p className="mt-2 text-muted-foreground">
Registado por {o.snapshot.actor.name} ·
Responsável: {o.snapshot.responsible.name}
</p>
</article>
))}
{operations.data.length === 0 && (
<p className="text-sm">Sem operações registadas.</p>
)}
</div>
<InventoryPagination page={operations} />
</Panel>
</div>
</>
);
}
export function OperationsDemo() {
const snapshot = {
actor: { name: 'Operador fictício de armazém' },
responsible: { name: 'Responsável fictício de produção' },
stock_item: { name: 'Papel A4 de exemplo', unit: 'resma' },
warehouse: { name: 'Central fictício' },
destination_warehouse: null,
};
const base = {
stock_item_id: 1,
warehouse_id: 1,
recorded_at: '2026-10-28T10:00:00+02:00',
snapshot,
theoretical_milli: 12000,
};
const data: Operation[] = [
{
...base,
id: 3,
kind: 'count',
reference: 'INV-DEMO-003',
quantity_milli: 11000,
reason: 'Contagem física fictícia: menos uma resma que o saldo teórico.',
},
{
...base,
id: 2,
kind: 'transfer',
reference: 'TRF-DEMO-002',
quantity_milli: 3000,
reason: 'Reposição do armazém secundário.',
snapshot: {
...snapshot,
destination_warehouse: { name: 'Secundário fictício' },
},
},
{
...base,
id: 1,
kind: 'issue',
reference: 'SAI-DEMO-001',
quantity_milli: 2000,
destination: 'Produção fictícia · REQ-DEMO-001',
reason: 'Consumo de material na produção.',
},
{
...base,
id: 4,
kind: 'adjustment',
reference: 'AJ-DEMO-004',
quantity_milli: 11000,
reason: 'Exemplo independente de ajuste de −1 resma: divergência física confirmada e fundamentada na contagem INV-DEMO-004.',
},
];
return (
<Operations
demo
canOperate
items={[
{
id: 1,
code: 'PAP-DEMO',
name: 'Papel A4 fictício',
unit: 'resma',
},
]}
warehouses={[
{ id: 1, code: 'CENTRAL', name: 'Central fictício' },
{ id: 2, code: 'SEC', name: 'Secundário fictício' },
]}
users={[{ id: 1, name: 'Responsável fictício' }]}
balances={[
{
stock_item_id: 1,
warehouse_id: 1,
quantity: 12000,
version: 3,
},
]}
counts={[data[0]]}
operations={{ data, prev_page_url: null, next_page_url: null }}
/>
);
}
+95
View File
@@ -0,0 +1,95 @@
export type StockItem = {
id: number;
code: string;
name: string;
unit: string;
};
export type Warehouse = { id: number; code: string; name: string };
export type ReceiptLineProgress = {
id: number;
description: string;
unit: string;
purchase_type: string;
ordered_milli: number;
received_milli: number;
accepted_milli: number;
rejected_milli: number;
delivery_pending_milli: number;
acceptance_pending_milli: number;
};
export type ReceiptProgress = {
lines: ReceiptLineProgress[];
state: string;
has_divergences: boolean;
};
export type ReceiptItem = {
id: number;
received_milli: number;
accepted_milli: number;
rejected_milli: number;
quality_status: string;
notes: string | null;
snapshot: {
order_item: { description: string; unit: string };
stock_item: StockItem | null;
warehouse: Warehouse | null;
physical: boolean;
};
};
export type Receipt = {
id: number;
number: string;
purchase_order_id: number;
delivery_note: string;
received_on: string;
confirmed_at: string;
status: string;
snapshot: {
order: { id: number; number: string };
supplier: { name: string };
receiver: { name: string };
warehouse: Warehouse | null;
};
items: ReceiptItem[];
};
export type StockMovement = {
id: number;
quantity_milli: number;
moved_at: string;
moved_by: number;
stock_item_id: number;
warehouse_id: number;
movement_type: string;
stock_operation_id?: number | null;
snapshot: {
stock_item: StockItem;
warehouse: Warehouse;
order_number?: string;
delivery_note?: string;
receipt_id?: number;
reference?: string;
reason?: string;
equipment?: { id: number; code: string; description: string } | null;
};
};
export type Page<T> = {
data: T[];
prev_page_url: string | null;
next_page_url: string | null;
};
export const quantity = (milli: number) =>
new Intl.NumberFormat('pt-MZ', { maximumFractionDigits: 3 }).format(
milli / 1000,
);
export const receiptStates: Record<string, string> = {
not_received: 'Sem recepção',
partial: 'Recepção parcial',
complete: 'Recepção total aceite',
rejected: 'Recepção rejeitada',
quality_pending: 'Entrega completa com divergências',
};
export const qualityStates: Record<string, string> = {
compliant: 'Conforme',
reservations: 'Conforme com reservas',
nonconforming: 'Não conforme',
};
+4
View File
@@ -18,6 +18,10 @@ export type RequisitionItem = {
description: string;
category: string;
purchase_type: string;
equipment_id?: number | null;
spare_part_id?: number | null;
production_area?: string | null;
downtime_started_at?: string | null;
quantity: string;
unit: string;
unit_price_minor: number;