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

860 lines
40 KiB
TypeScript

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 exigem
regra de investimento vigente, com duas aprovações
administrativas, e reserva em dotação de investimento. A
validação técnica não substitui estes controlos.
</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,
}}
/>
);
}