Consolida recepcoes, stock, equipamentos e identidade SGC
This commit is contained in:
@@ -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/'):
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }] };
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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: '#' },
|
||||
],
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}{' '}
|
||||
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ $order->number }} · ProcuraFlow</title>
|
||||
<title>{{ $order->number }} · SGC</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 17mm; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
Reference in New Issue
Block a user