This commit is contained in:
2026-11-02 20:27:11 +02:00
parent b44378b899
commit badaada043
300 changed files with 17458 additions and 1056 deletions
@@ -0,0 +1,190 @@
import { Link } from '@inertiajs/react';
import { Panel } from '@/components/procurement';
type Person = {
id: number;
name: string;
current_permissions: string[];
configured_permissions: string[];
added: string[];
removed: string[];
current_can_request: boolean;
configured_can_request: boolean;
configured_maintenance_only: boolean;
permanent_admin: boolean;
current_contexts: string[] | null;
configured_contexts: string[] | null;
loses_access: boolean;
};
export type ActivationPreview = {
ready: boolean;
active?: boolean;
version?: number;
checked_at?: string;
users: Person[];
permanent_administrators?: number;
provisional_rules?: boolean;
can_activate?: boolean;
losing_access?: number;
context_labels?: Record<string, string>;
};
export function OrganizationActivationPreview({
preview,
permissions,
tasks,
}: {
preview: ActivationPreview;
permissions: Record<string, string>;
tasks: Record<string, { key: string; label: string }[]>;
}) {
if (!preview.ready) return null;
const labels = (keys: string[], source = permissions) =>
keys.map((key) => source[key] ?? key).join('; ') || 'Nenhuma';
return (
<Panel
title={
preview.active
? 'Acessos e tarefas da configuração activa'
: 'Conferir o efeito da activação'
}
description="Esta consulta reutiliza os perfis e as atribuições guardados. Não altera acessos, não cria contas e não decide processos."
>
<div className="space-y-4 text-sm">
<p>
{preview.users.length} utilizador(es) ·{' '}
{preview.permanent_administrators} administrador(es)
permanente(s)
{preview.active
? ''
: ` · ${preview.losing_access} utilizador(es) ficaria(m) sem competência de acesso`}
</p>
{!preview.active && (
<p className="rounded border p-3">
{preview.can_activate
? 'As condições existentes de dois administradores permanentes e regras confirmadas estão preenchidas. Confira o efeito em cada pessoa antes de activar.'
: 'A activação exige dois administradores permanentes atribuídos e regras de aprovação confirmadas.'}
{preview.provisional_rules &&
' As regras actuais continuam provisórias.'}
</p>
)}
<p>
As tarefas descritas dependem também do processo,
departamento, contexto, independência e competências
específicas. Uma competência financeira não substitui os
acessos próprios às obrigações financeiras.
</p>
<Link
href="/administracao/parametrizacao"
preserveScroll
className="inline-block font-semibold underline"
>
Actualizar diagnóstico e configuração
</Link>
{preview.users.map((person) => (
<details key={person.id} className="rounded border p-3">
<summary className="cursor-pointer font-semibold">
{person.name}
{person.loses_access && !preview.active
? ' · Ficaria sem acesso'
: ''}
{person.permanent_admin
? ' · Administração permanente'
: ''}
</summary>
<div className="mt-3 space-y-3">
<p>
<strong>Competências actuais:</strong>{' '}
{labels(person.current_permissions)}
</p>
<p>
<strong>
Competências dos perfis guardados:
</strong>{' '}
{labels(person.configured_permissions)}
</p>
{!preview.active && (
<>
<p>
<strong>Acrescentadas:</strong>{' '}
{labels(person.added)}
</p>
<p>
<strong>Retiradas:</strong>{' '}
{labels(person.removed)}
</p>
</>
)}
<p>
<strong>
Preparação de requisição de compra:
</strong>{' '}
actualmente{' '}
{person.current_can_request ? 'sim' : 'não'};
pelos perfis guardados{' '}
{person.configured_can_request ? 'sim' : 'não'}.
</p>
{person.configured_contexts === null ? (
<p>
A selecção de contextos ainda não está
activa nesta empresa.
</p>
) : (
<>
<p>
<strong>Contextos actuais:</strong>{' '}
{labels(
person.current_contexts ?? [],
preview.context_labels,
)}
</p>
<p>
<strong>
Contextos pelos perfis guardados:
</strong>{' '}
{labels(
person.configured_contexts,
preview.context_labels,
)}
</p>
</>
)}
{person.configured_maintenance_only ? (
<p>
Manutenção temporária: diagnóstico e
consulta, sem operações de negócio.
</p>
) : (
<div>
<strong>
Tarefas associadas às competências
guardadas:
</strong>
<ul className="mt-2 list-disc space-y-1 pl-5">
{person.configured_permissions
.flatMap(
(permission) =>
tasks[permission] ?? [],
)
.map((task) => (
<li key={task.key}>
{task.label}
</li>
))}
{person.configured_permissions
.length === 0 && (
<li>
Sem tarefas atribuídas pelos
perfis.
</li>
)}
</ul>
</div>
)}
</div>
</details>
))}
</div>
</Panel>
);
}
@@ -49,6 +49,11 @@ export function procurementNavigation(
label: 'Minhas requisições',
path: '/requisicoes?view=mine',
},
{
id: 'my_workflow_tasks',
label: 'As minhas tarefas',
path: '/requisicoes/tarefas',
},
{
id: 'my_drafts',
label: 'Os meus rascunhos',
@@ -196,6 +201,18 @@ export function procurementNavigation(
label: 'Pessoas e contratações',
icon: Users,
entries: [
{
id: 'people_workflow_tasks',
label: 'As minhas tarefas',
path: '/pessoas/tarefas',
roles: [
'admin',
'people_service_prepare',
'people_service_review',
'people_staff_prepare',
'people_staff_review',
],
},
{
id: 'people_registry',
label: 'Cadastro reutilizável de pessoas',
@@ -305,6 +322,12 @@ export function procurementNavigation(
'supply_review',
],
},
{
id: 'warehouse_pilot',
label: 'Preparação do piloto',
path: '/stock/piloto',
roles: ['admin'],
},
{
id: 'warehouse_requests',
label: 'Requisições ao armazém',
+11
View File
@@ -14,6 +14,8 @@ import {
import { Button } from '@/components/ui/button';
import { WorkContextSettings } from '@/components/work-context-settings';
import type { WorkContextState } from '@/types/work-context';
import { OrganizationActivationPreview } from '@/components/organization-activation-preview';
import type { ActivationPreview } from '@/components/organization-activation-preview';
type Profile = {
id: number;
@@ -168,6 +170,7 @@ export default function Parameters({
version,
permissions,
workflowTasks = {},
activationPreview,
profiles,
memberships,
users,
@@ -197,6 +200,7 @@ export default function Parameters({
version: number;
permissions: { [key: string]: string };
workflowTasks?: Record<string, { key: string; label: string }[]>;
activationPreview?: ActivationPreview;
profiles: Profile[];
memberships: Member[];
users: Person[];
@@ -273,6 +277,13 @@ export default function Parameters({
{notice}
</p>
)}
{activationPreview && (
<OrganizationActivationPreview
preview={activationPreview}
permissions={permissions}
tasks={workflowTasks}
/>
)}
{!ready ? (
<Panel title="Instalação pendente">
<p>
+111 -67
View File
@@ -1,4 +1,4 @@
import { Head, router, useForm, usePage } from '@inertiajs/react';
import { Head, Link, router, useForm } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
@@ -32,6 +32,7 @@ type Invoice = {
issued_on: string;
status: string;
created_by: number;
canValidate: boolean;
subtotal_minor: number;
tax_minor: number;
total_minor: number;
@@ -51,10 +52,14 @@ export default function Invoices({
payablesAvailable = false,
stockReturns = [],
stockReversals = [],
focusInvoiceId = null,
focusOrderId = null,
}: {
invoices: Page<Invoice>;
orders: { id: number; number: string; supplier: string; items: Source[] }[];
canManage: boolean;
focusInvoiceId?: number | null;
focusOrderId?: number | null;
payablesAvailable?: boolean;
stockReturns?: {
reference: string;
@@ -72,8 +77,9 @@ export default function Invoices({
event_on: string;
}[];
}) {
const { auth } = usePage().props;
const [orderId, setOrderId] = useState('');
const [orderId, setOrderId] = useState(
focusOrderId === null ? '' : String(focusOrderId),
);
const [validationError, setValidationError] = useState('');
const [validating, setValidating] = useState<number | null>(null);
const form = useForm({
@@ -82,7 +88,15 @@ export default function Invoices({
subtotal: '',
tax: '',
total: '',
items: [] as Line[],
items: (orders
.find((source) => source.id === focusOrderId)
?.items.map((line) => ({
purchase_order_item_id: line.id,
quantity: '',
unit: line.unit,
unit_price: (line.unit_price_minor / 100).toFixed(2),
tax_rate: String(line.tax_rate),
})) ?? []) as Line[],
});
const order = orders.find((o) => String(o.id) === orderId);
return (
@@ -148,10 +162,15 @@ export default function Invoices({
</ul>
</Panel>
)}
{canManage && (
{(focusInvoiceId !== null || focusOrderId !== null) && (
<Link href="/facturas" className="underline">
Todas as facturas / seleccionar outra encomenda
</Link>
)}
{canManage && focusInvoiceId === null && (
<Panel
title="Registar factura"
description="Transcreva os valores do documento do fornecedor. A conferência exige coincidência exacta; o registo não valida automaticamente a factura."
description="Reutilize a encomenda e indique os dados novos do documento do fornecedor. Unidade, preço e IVA vêm da encomenda; os totais do documento continuam a exigir coincidência exacta e conferência independente."
>
<form
className="grid gap-4"
@@ -159,9 +178,17 @@ export default function Invoices({
e.preventDefault();
form.transform((d) => ({
...d,
items: d.items.filter(
(l) => l.quantity !== '',
),
items: d.items
.filter((l) => l.quantity !== '')
.map(
({
purchase_order_item_id,
quantity,
}) => ({
purchase_order_item_id,
quantity,
}),
),
}));
form.post(`/ordens/${orderId}/facturas`, {
onSuccess: () => {
@@ -268,25 +295,31 @@ export default function Invoices({
tax_rate: 'IVA (%)',
}[key]
}
<Input
value={line[key]}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(l, i) =>
i === index
? {
...l,
[key]: e
.target
.value,
}
: l,
),
)
}
/>
{key === 'quantity' ? (
<Input
value={line[key]}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(l, i) =>
i === index
? {
...l,
[key]: e
.target
.value,
}
: l,
),
)
}
/>
) : (
<p className="rounded border bg-muted/30 px-3 py-2">
{line[key]}
</p>
)}
</label>
))}
</fieldset>
@@ -334,45 +367,55 @@ export default function Invoices({
</p>
)}
{invoices.data.map((invoice) => (
<Panel
key={invoice.id}
title={`${invoice.number} · ${invoice.snapshot.supplier.name}`}
>
<p>
{invoice.snapshot.order_number} ·{' '}
{invoice.issued_on} ·{' '}
{invoice.status === 'validated'
? 'Validada'
: 'Pendente de validação'}
</p>
<p>
Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
{money(invoice.tax_minor)} · Total{' '}
{money(invoice.total_minor)}
</p>
{payablesAvailable && (
<p className="my-3">
<a
className="underline"
href={`/obrigacoes-fornecedores?invoice=${invoice.id}`}
>
Consultar ou preparar obrigação desta
factura
</a>
<div key={invoice.id} id={`invoice-${invoice.id}`}>
<Panel
title={`${invoice.number} · ${invoice.snapshot.supplier.name}`}
>
<p>
{invoice.snapshot.order_number} ·{' '}
{invoice.issued_on} ·{' '}
{invoice.status === 'validated'
? 'Validada'
: 'Pendente de validação'}
</p>
)}
<ul className="my-3 space-y-2">
{invoice.items.map((l) => (
<li key={l.id}>
{l.snapshot.description} ·{' '}
{l.quantity_milli / 1000} {l.snapshot.unit}{' '}
· {money(l.subtotal_minor + l.tax_minor)}
</li>
))}
</ul>
{canManage &&
invoice.status === 'pending' &&
invoice.created_by !== auth.user?.id && (
<p>
Subtotal {money(invoice.subtotal_minor)} · IVA{' '}
{money(invoice.tax_minor)} · Total{' '}
{money(invoice.total_minor)}
</p>
{payablesAvailable && (
<p className="my-3">
<a
className="underline"
href={`/obrigacoes-fornecedores?invoice=${invoice.id}`}
>
Consultar ou preparar obrigação desta
factura
</a>
</p>
)}
{invoice.status === 'validated' && (
<p className="my-3">
<Link
className="underline"
href={`/pagamentos?invoice_id=${invoice.id}`}
>
Consultar pagamentos externos desta
factura
</Link>
</p>
)}
<ul className="my-3 space-y-2">
{invoice.items.map((l) => (
<li key={l.id}>
{l.snapshot.description} ·{' '}
{l.quantity_milli / 1000}{' '}
{l.snapshot.unit} ·{' '}
{money(l.subtotal_minor + l.tax_minor)}
</li>
))}
</ul>
{invoice.canValidate && (
<Button
disabled={validating !== null}
onClick={() => {
@@ -397,7 +440,8 @@ export default function Invoices({
Conferir e validar
</Button>
)}
</Panel>
</Panel>
</div>
))}
{invoices.data.length === 0 && (
<p>Sem facturas registadas nesta empresa.</p>
+43 -31
View File
@@ -1,4 +1,4 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import {
FormErrors,
@@ -24,6 +24,7 @@ type Invoice = {
bank_state: string;
};
type Payment = {
canReview: boolean;
id: number;
invoice_id: number;
created_by: number;
@@ -90,15 +91,21 @@ export default function Payments({
invoices,
payments,
canManage,
focusPaymentId = null,
focusInvoiceId = null,
}: {
invoices: Invoice[];
payments: Page<Payment>;
canManage: boolean;
focusPaymentId?: number | null;
focusInvoiceId?: number | null;
}) {
const { auth } = usePage().props;
const [invoiceId, setInvoiceId] = useState('');
const [invoiceId, setInvoiceId] = useState(
focusInvoiceId === null ? '' : String(focusInvoiceId),
);
const form = useForm({
supplier_bank_detail_id: 0,
supplier_bank_detail_id:
invoices.find((i) => i.id === focusInvoiceId)?.bank_id ?? 0,
amount: '',
paid_on: '',
external_reference: '',
@@ -157,7 +164,15 @@ export default function Payments({
)}
</div>
</Panel>
{canManage && (
{(focusPaymentId !== null || focusInvoiceId !== null) && (
<Link
href="/pagamentos"
className="font-semibold text-teal-700"
>
Ver todos os registos externos
</Link>
)}
{canManage && focusPaymentId === null && (
<Panel
title="Registar pagamento externo para conferência"
description="Uma referência externa identifica um registo por empresa. Use a referência da operação e a evidência do pagamento; não introduza números de conta nestes campos."
@@ -276,34 +291,31 @@ export default function Payments({
</Panel>
)}
{payments.data.map((p) => (
<Panel
key={p.id}
title={`Registo externo #${p.id} · ${invoices.find((i) => i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
>
<p>
{money(p.amount_minor)} · {p.paid_on} ·{' '}
{{
pending: 'Pendente de conferência',
confirmed: 'Confirmado',
rejected: 'Conferência rejeitada',
}[p.status] ?? p.status}
</p>
{p.external_reference && (
<p className="mt-2 text-sm break-all">
Referência: {p.external_reference}
<div key={p.id} id={`payment-${p.id}`}>
<Panel
title={`Registo externo #${p.id} · ${invoices.find((i) => i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
>
<p>
{money(p.amount_minor)} · {p.paid_on} ·{' '}
{{
pending: 'Pendente de conferência',
confirmed: 'Confirmado',
rejected: 'Conferência rejeitada',
}[p.status] ?? p.status}
</p>
)}
{p.evidence_reference && (
<p className="text-sm break-all">
Evidência: {p.evidence_reference}
</p>
)}
{canManage &&
p.status === 'pending' &&
p.created_by !== auth.user?.id && (
<Review id={p.id} />
{p.external_reference && (
<p className="mt-2 text-sm break-all">
Referência: {p.external_reference}
</p>
)}
</Panel>
{p.evidence_reference && (
<p className="text-sm break-all">
Evidência: {p.evidence_reference}
</p>
)}
{p.canReview && <Review id={p.id} />}
</Panel>
</div>
))}
<InventoryPagination page={payments} />
</div>
+13 -2
View File
@@ -44,6 +44,7 @@ type Decision = {
reviews: Review[];
};
type Props = {
focusDecisionId?: number | null;
requisition: { id: number; number: string };
policy: { id: number; revision: number; enabled: boolean } | null;
adoption: Adoption | null;
@@ -245,6 +246,7 @@ export default function Admission({
canAdopt,
canPropose,
closed,
focusDecisionId = null,
}: Props) {
const base = `/requisicoes/${requisition.id}`;
return (
@@ -281,7 +283,7 @@ export default function Admission({
O processo está fechado para alterações de admissão.
</p>
)}
{canAdopt && (
{focusDecisionId === null && canAdopt && (
<Adopt
key={policy?.id}
requisition={requisition}
@@ -320,7 +322,7 @@ export default function Admission({
</p>
</Panel>
)}
{canPropose && (
{focusDecisionId === null && canPropose && (
<Panel title="Propor decisões">
<div className="space-y-5">
{proposals.map((p) => (
@@ -339,12 +341,21 @@ export default function Admission({
</Panel>
)}
<Panel title="Tentativas e conferências">
{focusDecisionId !== null && (
<Link
className="mb-4 inline-block underline"
href={`${base}/admissao${selected ? `?version=${selected.id}` : ''}`}
>
Ver todas as tentativas desta versão
</Link>
)}
<div className="space-y-5">
{history.data.length === 0 && (
<p>Ainda não há tentativas nesta adopção.</p>
)}
{history.data.map((d) => (
<article
id={`admission-${d.id}`}
key={d.id}
className="rounded-md border p-4"
>
+28 -14
View File
@@ -65,6 +65,7 @@ type Props = {
canDefine: boolean;
canAssess: boolean;
closedForChanges: boolean;
focusQuotationId?: number | null;
};
const categories: Record<string, string> = {
legal: 'Jurídica',
@@ -257,10 +258,12 @@ function AssessForm({
selected,
records,
actorId,
focusQuotationId,
}: {
selected: Set;
records: Assessment[];
actorId: number;
focusQuotationId: number | null;
}) {
const blank = (): Answer[] =>
selected.criteria.map((criterion) => ({
@@ -269,11 +272,18 @@ function AssessForm({
evidence: '',
reason: '',
}));
const initial = records.find(
(record) =>
record.quotation_id === focusQuotationId &&
record.analyst_id === actorId,
);
const form = useForm({
previous_id: 0,
quotation_id: '',
previous_id: initial?.id ?? 0,
quotation_id: focusQuotationId === null ? '' : String(focusQuotationId),
reason: '',
answers: blank(),
answers: initial
? initial.answers.map((answer) => ({ ...answer }))
: blank(),
});
const choose = (id: string) => {
const current = records.find(
@@ -532,6 +542,7 @@ export default function QualificationPage(props: Props) {
canDefine,
canAssess,
closedForChanges,
focusQuotationId = null,
} = props;
const { errors } = usePage().props;
const base = `/requisicoes/${requisition.id}/qualificacao`;
@@ -656,17 +667,20 @@ export default function QualificationPage(props: Props) {
)}
</Panel>
{canAssess && (
<Panel
title="A minha análise documental"
description="O analista deve ser distinto do solicitante. Resultados preenchidos exigem fundamento e, quando aplicável, referência de evidência."
>
<AssessForm
key={selected.id}
selected={selected}
records={records}
actorId={actorId}
/>
</Panel>
<div id="qualification-assess">
<Panel
title="A minha análise documental"
description="O analista deve ser distinto do solicitante. Resultados preenchidos exigem fundamento e, quando aplicável, referência de evidência."
>
<AssessForm
key={`${selected.id}-${focusQuotationId ?? 'all'}-${focusQuotationId === null ? 0 : (records.find((record) => record.quotation_id === focusQuotationId && record.analyst_id === actorId)?.id ?? 0)}`}
selected={selected}
records={records}
actorId={actorId}
focusQuotationId={focusQuotationId}
/>
</Panel>
</div>
)}
<Panel
title="Último registo de cada analista por proposta"
@@ -52,6 +52,7 @@ type Attempt = {
reviews: Review[];
};
type Props = {
focusAttemptId?: number | null;
requisition: { id: number; number: string };
policy: { id: number; revision: number; enabled: boolean } | null;
adoption: Adoption | null;
@@ -310,6 +311,7 @@ export default function Recommendation(props: Props) {
canPropose,
closed,
hasAdmission,
focusAttemptId = null,
} = props;
const base = `/requisicoes/${requisition.id}`;
return (
@@ -346,7 +348,7 @@ export default function Recommendation(props: Props) {
alterada exige o circuito de revisão autorizado.
</p>
)}
{canAdopt && (
{focusAttemptId === null && canAdopt && (
<Adopt
key={`${policy?.id}-${adoption?.id}`}
requisition={requisition}
@@ -399,7 +401,7 @@ export default function Recommendation(props: Props) {
</p>
</Panel>
)}
{canPropose && (
{focusAttemptId === null && canPropose && (
<Panel title="Proposta manual por Compras">
<Propose
key={`${selected?.id}-${previousId}-${basis.hash}`}
@@ -410,12 +412,21 @@ export default function Recommendation(props: Props) {
</Panel>
)}
<Panel title="Tentativas e conferências">
{focusAttemptId !== null && (
<Link
className="mb-4 inline-block underline"
href={`${base}/recomendacao${selected ? `?version=${selected.id}` : ''}`}
>
Ver todas as tentativas desta versão
</Link>
)}
<div className="space-y-5">
{history.data.length === 0 && (
<p>Sem tentativas nesta adopção.</p>
)}
{history.data.map((attempt) => (
<article
id={`recommendation-${attempt.id}`}
className="rounded-lg border p-4"
key={attempt.id}
>
+319 -309
View File
@@ -1,4 +1,4 @@
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { ComparisonPolicySummary } from '@/components/comparison-policy-summary';
import type { ComparisonPolicy } from '@/components/comparison-policy-summary';
@@ -33,6 +33,7 @@ type Evaluation = {
created_at: string;
};
type Quote = {
canEvaluate: boolean;
id: number;
reference: string;
supplier: { name: string };
@@ -56,7 +57,8 @@ export default function Comparison({
quotations,
suppliers,
canCreate,
canEvaluate,
focusQuotationId = null,
prepare = false,
ready,
topIds,
comparisonPolicy,
@@ -71,13 +73,14 @@ export default function Comparison({
suppliers: { id: number; name: string }[];
canCreate: boolean;
canEvaluate: boolean;
focusQuotationId?: number | null;
prepare?: boolean;
ready: boolean;
topIds: number[];
comparisonPolicy: ComparisonPolicy;
}) {
const { auth } = usePage().props;
const [adding, setAdding] = useState(false);
const [selected, setSelected] = useState<number | null>(null);
const [adding, setAdding] = useState(prepare && canCreate);
const [selected, setSelected] = useState<number | null>(focusQuotationId);
const form = useForm({
supplier_id: '',
reference: '',
@@ -188,265 +191,273 @@ export default function Comparison({
selecção deve ficar fundamentada na adjudicação.
</p>
)}
{adding && (
<Panel
title="Nova proposta"
description="MZN · Quantidades e IVA herdados da requisição. Desconto por unidade antes do IVA; preço líquido arredondado ao centavo antes de multiplicar pela quantidade. Preencha todas as linhas."
>
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/requisicoes/${requisition.id}/cotacoes`,
{
onSuccess: () => {
form.reset();
setAdding(false);
},
},
);
}}
{canCreate && adding && (
<div id="quotation-create">
<Panel
title="Nova proposta"
description="MZN · Quantidades e IVA herdados da requisição. Desconto por unidade antes do IVA; preço líquido arredondado ao centavo antes de multiplicar pela quantidade. Preencha todas as linhas."
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supplier">
Fornecedor activo
</Label>
<select
id="supplier"
className={controlClass}
required
value={form.data.supplier_id}
onChange={(e) =>
form.setData(
'supplier_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{suppliers.map((s) => (
<option value={s.id} key={s.id}>
{s.name}
</option>
))}
</select>
</div>
{(
[
[
'reference',
'Referência do fornecedor',
'text',
],
[
'delivery_days',
'Prazo de entrega (dias)',
'number',
],
['valid_until', 'Válida até', 'date'],
[
'payment_terms',
'Condições de pagamento',
'text',
],
] as const
).map(([key, label, type]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type={type}
min={
type === 'number'
? 1
: undefined
}
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
form.post(
`/requisicoes/${requisition.id}/cotacoes`,
{
onSuccess: () => {
form.reset();
setAdding(false);
},
},
);
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supplier">
Fornecedor activo
</Label>
<select
id="supplier"
className={controlClass}
required
value={form.data[key]}
value={form.data.supplier_id}
onChange={(e) =>
form.setData(
key,
'supplier_id',
e.target.value,
)
}
/>
>
<option value="">Seleccione</option>
{suppliers.map((s) => (
<option value={s.id} key={s.id}>
{s.name}
</option>
))}
</select>
</div>
))}
</div>
<div className="space-y-4">
{requisition.items.map((i, index) => (
<div
className="grid items-center gap-3 rounded-lg border p-4 md:grid-cols-2"
key={i.id}
>
<div>
<strong className="text-sm">
{(
[
[
'reference',
'Referência do fornecedor',
'text',
],
[
'delivery_days',
'Prazo de entrega (dias)',
'number',
],
[
'valid_until',
'Válida até',
'date',
],
[
'payment_terms',
'Condições de pagamento',
'text',
],
] as const
).map(([key, label, type]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={key}>{label}</Label>
<Input
id={key}
type={type}
min={
type === 'number'
? 1
: undefined
}
required
value={form.data[key]}
onChange={(e) =>
form.setData(
key,
e.target.value,
)
}
/>
</div>
))}
</div>
<div className="space-y-4">
{requisition.items.map((i, index) => (
<div
className="grid items-center gap-3 rounded-lg border p-4 md:grid-cols-2"
key={i.id}
>
<div>
<strong className="text-sm">
{i.description}
</strong>
<p className="text-xs text-muted-foreground">
{i.quantity} unidades · IVA{' '}
{i.tax_rate}%
</p>
</div>
<div className="space-y-2">
<Label
htmlFor={`price-${i.id}`}
>
Preço unitário antes de
desconto e IVA (MZN)
</Label>
<Input
id={`price-${i.id}`}
type="number"
min="0.01"
step="0.01"
required
value={
form.data.items[index]
.unit_price
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
unit_price:
e
.target
.value,
}
: line,
),
)
}
/>
</div>
</div>
))}
</div>
<fieldset className="space-y-4 rounded-lg border p-4">
<legend className="px-2 font-medium">
Descontos unitários antes do IVA
</legend>
{requisition.items.map((i, index) => (
<div
key={i.id}
className="grid gap-3 md:grid-cols-3"
>
<span className="text-sm">
{i.description}
</strong>
<p className="text-xs text-muted-foreground">
{i.quantity} unidades · IVA{' '}
{i.tax_rate}%
</p>
</span>
<label className="text-sm">
Tipo de desconto
<select
className={controlClass}
value={
form.data.items[index]
.discount_type
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_type:
e
.target
.value,
discount_value:
'0',
}
: line,
),
)
}
>
<option value="none">
Sem desconto
</option>
<option value="percent">
Percentagem (%)
</option>
<option value="amount">
Valor por unidade (MZN)
</option>
</select>
</label>
<label className="text-sm">
{form.data.items[index]
.discount_type === 'percent'
? 'Percentagem (%)'
: 'Valor unitário (MZN)'}
<Input
type="number"
min="0"
step="0.01"
required
disabled={
form.data.items[index]
.discount_type ===
'none'
}
max={
form.data.items[index]
.discount_type ===
'percent'
? '100'
: '1000000000'
}
value={
form.data.items[index]
.discount_value
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_value:
e
.target
.value,
}
: line,
),
)
}
/>
</label>
</div>
<div className="space-y-2">
<Label htmlFor={`price-${i.id}`}>
Preço unitário antes de desconto
e IVA (MZN)
</Label>
<Input
id={`price-${i.id}`}
type="number"
min="0.01"
step="0.01"
required
value={
form.data.items[index]
.unit_price
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
unit_price:
e
.target
.value,
}
: line,
),
)
}
/>
</div>
</div>
))}
</div>
<fieldset className="space-y-4 rounded-lg border p-4">
<legend className="px-2 font-medium">
Descontos unitários antes do IVA
</legend>
{requisition.items.map((i, index) => (
<div
key={i.id}
className="grid gap-3 md:grid-cols-3"
>
<span className="text-sm">
{i.description}
</span>
<label className="text-sm">
Tipo de desconto
<select
className={controlClass}
value={
form.data.items[index]
.discount_type
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_type:
e
.target
.value,
discount_value:
'0',
}
: line,
),
)
}
>
<option value="none">
Sem desconto
</option>
<option value="percent">
Percentagem (%)
</option>
<option value="amount">
Valor por unidade (MZN)
</option>
</select>
</label>
<label className="text-sm">
{form.data.items[index]
.discount_type === 'percent'
? 'Percentagem (%)'
: 'Valor unitário (MZN)'}
<Input
type="number"
min="0"
step="0.01"
required
disabled={
form.data.items[index]
.discount_type ===
'none'
}
max={
form.data.items[index]
.discount_type ===
'percent'
? '100'
: '1000000000'
}
value={
form.data.items[index]
.discount_value
}
onChange={(e) =>
form.setData(
'items',
form.data.items.map(
(line, k) =>
k === index
? {
...line,
discount_value:
e
.target
.value,
}
: line,
),
)
}
/>
</label>
</div>
))}
</fieldset>
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData(
'no_conflict',
e.target.checked,
)
}
/>
Declaro que não tenho conflito de interesses com
este fornecedor.
</label>
<Button disabled={form.processing}>
Guardar proposta
</Button>
</form>
</Panel>
))}
</fieldset>
<label className="flex items-start gap-3 text-sm">
<input
type="checkbox"
required
checked={form.data.no_conflict}
onChange={(e) =>
form.setData(
'no_conflict',
e.target.checked,
)
}
/>
Declaro que não tenho conflito de interesses
com este fornecedor.
</label>
<Button disabled={form.processing}>
Guardar proposta
</Button>
</form>
</Panel>
</div>
)}
<Panel
title="Matriz de comparação"
@@ -544,62 +555,60 @@ export default function Comparison({
{quotations
.filter((q) => q.id === selected)
.map((q) => (
<Panel
key={q.id}
title={`${q.supplier.name} · ${q.reference}`}
description={`Pagamento: ${q.payment_terms}`}
>
{q.ineligibility_reasons.length > 0 && (
<ul className="mb-4 list-disc pl-5 text-sm text-amber-800 dark:text-amber-300">
{q.ineligibility_reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
)}
<QuotationCommercialTerms
terms={q.commercial_terms}
/>
<div className="space-y-3">
{q.items.map((i) => (
<p className="text-sm" key={i.id}>
{i.description} · {i.quantity} ×{' '}
{money(i.unit_price_minor)} · total com
IVA {money(i.total_minor)}
</p>
))}
</div>
<h3 className="mt-6 mb-3 font-semibold">
Avaliações registadas
</h3>
{q.evaluations.map((ev) => (
<div
key={ev.id}
className="mb-3 rounded-lg border p-4 text-sm"
>
<strong>
Avaliador #{ev.evaluator_id}
</strong>
<p className="text-xs text-muted-foreground">
Registada em{' '}
{new Date(ev.created_at).toLocaleString(
'pt-MZ',
<div key={q.id} id={`quotation-${q.id}`}>
<Panel
title={`${q.supplier.name} · ${q.reference}`}
description={`Pagamento: ${q.payment_terms}`}
>
{q.ineligibility_reasons.length > 0 && (
<ul className="mb-4 list-disc pl-5 text-sm text-amber-800 dark:text-amber-300">
{q.ineligibility_reasons.map(
(reason) => (
<li key={reason}>{reason}</li>
),
)}
</p>
<p>
Qualidade {ev.quality} · histórico{' '}
{ev.history} · pagamento {ev.payment}
</p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
{ev.comment}
</p>
</ul>
)}
<QuotationCommercialTerms
terms={q.commercial_terms}
/>
<div className="space-y-3">
{q.items.map((i) => (
<p className="text-sm" key={i.id}>
{i.description} · {i.quantity} ×{' '}
{money(i.unit_price_minor)} · total
com IVA {money(i.total_minor)}
</p>
))}
</div>
))}
{canEvaluate &&
q.eligible &&
!q.conflict_blocked &&
!q.evaluations.some(
(ev) => ev.evaluator_id === auth.user.id,
) && (
<h3 className="mt-6 mb-3 font-semibold">
Avaliações registadas
</h3>
{q.evaluations.map((ev) => (
<div
key={ev.id}
className="mb-3 rounded-lg border p-4 text-sm"
>
<strong>
Avaliador #{ev.evaluator_id}
</strong>
<p className="text-xs text-muted-foreground">
Registada em{' '}
{new Date(
ev.created_at,
).toLocaleString('pt-MZ')}
</p>
<p>
Qualidade {ev.quality} · histórico{' '}
{ev.history} · pagamento{' '}
{ev.payment}
</p>
<p className="mt-2 whitespace-pre-wrap text-muted-foreground">
{ev.comment}
</p>
</div>
))}
{q.canEvaluate && (
<form
className="mt-5 space-y-4 border-t pt-5"
onSubmit={(e) => {
@@ -701,7 +710,8 @@ export default function Comparison({
</Button>
</form>
)}
</Panel>
</Panel>
</div>
))}
</div>
</>
+52 -1
View File
@@ -19,9 +19,12 @@ type Audit = {
action: string;
user_id: number;
created_at: string;
execution?: string | null;
};
const actions: Record<string, string> = {
'requisition.created': 'Rascunho criado',
'requisition.automatically_created':
'Rascunho de reposição criado automaticamente',
'requisition.updated': 'Rascunho actualizado',
'requisition.submitted': 'Pedido submetido',
'approval.approved': 'Etapa aprovada',
@@ -37,6 +40,7 @@ export default function Show({
technical,
comparisonPolicy,
replenishmentSource,
automaticSource,
approvalRouting,
canRefreshRouting = false,
}: {
@@ -60,6 +64,18 @@ export default function Show({
origin: string;
} | null;
canEdit: boolean;
automaticSource?: {
need_id: number;
rule_id: number;
created_at: string;
origins: {
warehouse_request_id: number;
href: string | null;
destination: string;
equipment: { code: string; description: string } | null;
remaining_milli: number;
}[];
} | null;
canDecide: boolean;
conflictBlocked: boolean;
audit: Audit[];
@@ -122,6 +138,37 @@ export default function Show({
}
/>
<FormErrors errors={submission.errors} />
{automaticSource && (
<Panel title="Origem na reposição automática">
<p>
Preparada automaticamente em{' '}
{automaticSource.created_at}, pela parametrização #
{automaticSource.rule_id}. Necessidade #
{automaticSource.need_id}. O requisitante confere e
submete às aprovações habituais.
</p>
{automaticSource.origins.map((o, i) => (
<p key={i}>
{o.href ? (
<Link href={o.href}>
Requisição ao armazém RA-
{o.warehouse_request_id}
</Link>
) : (
<>
Requisição ao armazém RA-
{o.warehouse_request_id}
</>
)}{' '}
· Utilização: {o.destination}
{o.equipment
? ` · Equipamento ${o.equipment.code} · ${o.equipment.description}`
: ''}{' '}
· Saldo na origem: {o.remaining_milli / 1000}
</p>
))}
</Panel>
)}
{replenishmentSource && (
<Panel title="Origem na reposição">
<p className="text-sm">
@@ -648,7 +695,11 @@ export default function Show({
{actions[a.action] ?? a.action}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{date(a.created_at)} · Utilizador #
{date(a.created_at)} ·{' '}
{a.execution ===
'scheduled_replenishment'
? 'Execução periódica · Responsável de origem #'
: 'Utilizador #'}
{a.user_id}
</p>
</li>
+13 -8
View File
@@ -183,14 +183,19 @@ export default function Availability(props: Props) {
<td className="border-b p-3">
{!row.reconciled
? 'Reconciliar repartição dos lotes'
: row.usable_milli === 0
? 'Sem material utilizável — necessidade de compra'
: row.at_minimum
? 'No mínimo ou abaixo — necessidade de compra'
: row.minimum_milli ===
null
? 'Mínimo por determinar'
: 'Acima do mínimo'}
: row.free_milli !==
null &&
row.free_milli < 0
? 'Reserva com défice de material utilizável — rever qualidade, validade e atendimento'
: row.usable_milli ===
0
? 'Sem material utilizável — necessidade de compra'
: row.at_minimum
? 'No mínimo ou abaixo — necessidade de compra'
: row.minimum_milli ===
null
? 'Mínimo por determinar'
: 'Acima do mínimo'}
</td>
</tr>
))}
+3
View File
@@ -62,6 +62,9 @@ function EntryForm({ entry }: { entry: Entry }) {
>
<option value="consumable">Insumos / matéria-prima</option>
<option value="other">Outro bem</option>
<option value="spare_part">
Peça registada para equipamento
</option>
</select>
</label>
<label className="flex items-center gap-2">
+5 -1
View File
@@ -1,4 +1,4 @@
import { Head, useForm } from '@inertiajs/react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
import { InventoryNotice, InventoryPagination } from '@/components/inventory';
import { LotCountFields, countTotal } from '@/components/inventory-lot-count';
@@ -92,6 +92,7 @@ type Props = {
operations: Page<Operation>;
counts: Operation[];
canOperate: boolean;
canViewReturns?: boolean;
correctedOperations?: Record<number, string>;
canAdjust?: boolean;
lotCounts?: LotCountSource[];
@@ -112,6 +113,7 @@ export default function Operations({
operations,
counts,
canOperate,
canViewReturns = false,
correctedOperations = {},
canAdjust = false,
lotCounts = [],
@@ -171,6 +173,7 @@ export default function Operations({
description="Movimentos com fundamento, contagens físicas e histórico preservado."
/>
<InventoryNotice />
{canViewReturns && <Link href="/stock/devolucoes-internas">Conferir devoluções de excedentes</Link>}
{canOperate && form.data.kind === 'issue' && (
<label className="grid gap-2 text-sm">
Equipamento de destino (obrigatório para peças
@@ -534,6 +537,7 @@ export default function Operations({
'Devolução ao fornecedor',
write_off: 'Abate físico',
reversal: 'Estorno integral',
workshop_return: 'Devolução de excedente',
}[o.kind] ??
o.kind}
</strong>
+223
View File
@@ -0,0 +1,223 @@
import { Head, Link } from '@inertiajs/react';
import { PageTitle, Panel } from '@/components/procurement';
type Person = {
id: number;
name: string;
department_id: number | null;
has_cost_center?: boolean;
};
type Props = {
syntheticEvaluation: boolean;
report: {
checked_at: string;
warehouse_scenario_ready: boolean;
replenishment_scenario_ready: boolean;
client_configuration_complete: boolean;
checks: {
key: string;
label: string;
ok: boolean;
detail: string;
path: string | null;
}[];
participants: Record<string, Person[]>;
registered: Person[];
runtime: {
scanMinutes: number;
schedulerRecent: boolean;
schedulerStatus: { finished_at: string; result: string } | null;
};
};
};
const tasks: Record<string, string> = {
prepare: 'Preparar e confirmar requisições ao armazém',
review: 'Autorizar e acompanhar reservas',
deliver: 'Entregar pelo armazém (com independência)',
purchase_requester: 'Requisitante de compra',
supply_prepare: 'Preparar políticas e reposição',
supply_review: 'Conferir políticas e activar reposição',
};
export default function Pilot({ report: r, syntheticEvaluation }: Props) {
return (
<>
<Head title="Preparação do piloto" />
<div className="space-y-6 p-6">
<PageTitle
eyebrow="Administração · Armazém e reposição"
title="Preparação do piloto"
description="Conferir o que está instalado e parametrizado, antes de experimentar o percurso completo. Esta consulta não altera contas, permissões, regras ou stock."
/>
<p className="rounded border bg-slate-50 p-4 text-sm">
{syntheticEvaluation
? 'Avaliação de desenvolvimento: usar contas e dados sintéticos.'
: 'Diagnóstico da configuração desta empresa.'}{' '}
O resultado indica condições observadas, sem certificar
aceitação comercial, revisão visual completa ou cobertura
transferida para o SGF.
</p>
<nav className="flex flex-wrap gap-4 print:hidden">
<a href="/stock/piloto.csv">Exportar conferências (CSV)</a>
<button
type="button"
className="underline"
onClick={() => window.print()}
>
Imprimir diagnóstico
</button>
<Link href="/stock/requisicoes">
Acompanhamento de requisições
</Link>
<Link href="/stock/reposicao">Reposição automática</Link>
<Link href="/administracao/parametrizacao">
Parametrização da aplicação
</Link>
</nav>
<div className="grid gap-4 md:grid-cols-3">
{[
['Percurso de armazém', r.warehouse_scenario_ready],
['Reposição periódica', r.replenishment_scenario_ready],
[
'Configuração de cliente',
r.client_configuration_complete,
],
].map(([label, ok]) => (
<div key={String(label)} className="rounded border p-4">
<p className="font-semibold">{label}</p>
<p>
{ok
? 'Condições básicas observadas'
: 'Há preparação por concluir'}
</p>
</div>
))}
</div>
<Panel title="Conferências e próximos passos">
<p className="mb-4 text-sm">
A exportação regista a situação observada na data
indicada. Não activa configurações nem substitui o
ensaio dos percursos. Os dados podem mudar entre a
consulta e uma nova exportação.
</p>
<p className="mb-4 text-sm">
Conferido em{' '}
{new Date(r.checked_at).toLocaleString('pt-PT')}. Não
são presumidos valores, intervenientes ou aprovações em
falta.
</p>
{r.checks.map((c) => (
<div key={c.key} className="mb-3 rounded border p-4">
<p className="font-medium">
{c.ok ? '✓ Conferido' : 'A preparar'} ·{' '}
{c.label}
</p>
<p className="mt-1 text-sm">{c.detail}</p>
{c.path && (
<Link
className="mt-2 inline-block underline"
href={c.path}
>
Consultar / configurar
</Link>
)}
</div>
))}
</Panel>
<Panel title="Pessoas com tarefas actualmente elegíveis">
<p className="mb-4 text-sm">
Elegibilidade conforme as competências existentes,
contexto e departamento. Ter uma conta ou título de
função não concede as tarefas específicas deste
percurso. É necessário requisitante e autorizador do
mesmo departamento, centro de custo compatível e
entregador distinto de ambos.
</p>
<div className="grid gap-4 md:grid-cols-2">
{Object.entries(tasks).map(([key, label]) => (
<div key={key} className="rounded border p-3">
<p className="font-medium">{label}</p>
{r.participants[key]?.length ? (
<ul className="mt-2 list-inside list-disc text-sm">
{r.participants[key].map((p) => (
<li key={p.id}>
{p.name}
{p.department_id !== null
? ` · departamento #${p.department_id}`
: ''}
{(key === 'prepare' ||
key ===
'purchase_requester') &&
!p.has_cost_center
? ' · falta centro de custo no departamento'
: ''}
</li>
))}
</ul>
) : (
<p className="mt-2 text-sm">
Nenhuma conta elegível nesta tarefa.
Definir o perfil e a associação na
parametrização.
</p>
)}
</div>
))}
</div>
</Panel>
<Panel title="Contas já cadastradas">
<details>
<summary className="cursor-pointer">
Consultar {r.registered.length} conta(s), sem criar
novas identidades
</summary>
<ul className="mt-3 list-inside list-disc text-sm">
{r.registered.map((p) => (
<li key={p.id}>
{p.name} · #{p.id}
</li>
))}
</ul>
</details>
</Panel>
<Panel title="Roteiro do ensaio">
<ol className="list-inside list-decimal space-y-2 text-sm">
<li>
Conferir tarefas, centro de custo, artigo publicado
e armazém usando contas existentes.
</li>
<li>
Preparar e submeter uma requisição com local de
utilização e equipamento quando aplicável.
</li>
<li>
Autorizar com outra conta elegível, conferir reserva
e entregar com uma terceira conta independente.
</li>
<li>
Confirmar a recepção pelo requisitante; conferir
saldo e ausência de segunda saída.
</li>
<li>
Ensaiar entrega parcial, cancelamento apenas do
restante e material indisponível por
qualidade/validade.
</li>
<li>
Na reposição configurada, conferir um único rascunho
e manutenção das aprovações; testar recuperação em
base descartável.
</li>
</ol>
<p className="mt-4 text-sm">
Preparar ou diagnosticar não executa estes passos. A
preparação e a activação de reposição exigem
competências e independência próprias. O SGF real e a
proposta de identidade adicional mantêm o seu âmbito
separado.
</p>
</Panel>
</div>
</>
);
}
+215 -11
View File
@@ -20,6 +20,7 @@ type RecordRow = {
unit_price?: string;
tax_rate?: string;
needed_days?: number;
equipment_id?: number;
valid_until?: string;
message?: string;
purchases?: { id: number; number: string; quantity_milli: number }[];
@@ -37,13 +38,66 @@ type RecordRow = {
}[];
};
};
type Page<T> = {
data: T[];
total: number;
from: number | null;
to: number | null;
prev_page_url: string | null;
next_page_url: string | null;
};
function Pagination<T>({
page,
label,
}: {
page: Page<T> | null;
label: string;
}) {
return page ? (
<nav aria-label={label} className="mt-4 flex flex-wrap gap-4 text-sm">
<span>
{page.from ?? 0}–{page.to ?? 0} de {page.total}
</span>
{page.prev_page_url && (
<Link preserveScroll href={page.prev_page_url}>
Anterior
</Link>
)}
{page.next_page_url && (
<Link preserveScroll href={page.next_page_url}>
Seguinte
</Link>
)}
</nav>
) : null;
}
type Props = {
ready: boolean;
canPrepare: boolean;
canReview: boolean;
rules: RecordRow[];
needs: RecordRow[];
history: RecordRow[];
rules: Page<RecordRow> | null;
needs: Page<RecordRow> | null;
history: Page<RecordRow> | null;
filters: { need_state: string };
recoveryReady: boolean;
workItems: Page<{
id: number;
code: string;
name: string;
warehouse: string;
last_error: string | null;
due_at: string;
failures: number;
}> | null;
scanMinutes: number;
schedulerStatus: { finished_at: string; result: string } | null;
schedulerRecent: boolean;
work: {
total: number;
pending: number | null;
failed: number | null;
last_run_at: string | null;
} | null;
policies: RecordRow[];
requesters: { id: number; name: string; department_id: number }[];
centers: {
@@ -52,6 +106,13 @@ type Props = {
name: string;
department_id: number;
}[];
equipment: {
id: number;
code: string;
description: string;
department_id: number;
}[];
catalogueTypes: Record<number, string>;
};
const states: Record<string, string> = {
draft: 'Por activar',
@@ -76,6 +137,7 @@ function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
unit_price: rule?.data.unit_price ?? '',
tax_rate: rule?.data.tax_rate ?? '',
needed_days: rule?.data.needed_days?.toString() ?? '',
equipment_id: rule?.data.equipment_id?.toString() ?? '',
valid_until: rule?.data.valid_until ?? '',
},
});
@@ -86,7 +148,10 @@ function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
form.setData('data', {
...form.data.data,
[key]: value,
...(key === 'requester_id' ? { cost_center_id: '' } : {}),
...(key === 'requester_id'
? { cost_center_id: '', equipment_id: '' }
: {}),
...(key === 'policy_key' ? { equipment_id: '' } : {}),
});
return (
<form
@@ -150,6 +215,36 @@ function RuleForm({ props, rule }: { props: Props; rule?: RecordRow }) {
))}
</select>
</label>
{props.catalogueTypes[
props.policies.find(
(p) => p.record_key === form.data.data.policy_key,
)?.snapshot.item.id ?? 0
] === 'spare_part' && (
<label>
Equipamento de destino da peça
<select
required
className={controlClass}
value={form.data.data.equipment_id}
onChange={(e) => set('equipment_id', e.target.value)}
>
<option value="">
Seleccione um equipamento compatível
</option>
{props.equipment
.filter(
(e) =>
e.department_id ===
requester?.department_id,
)
.map((e) => (
<option key={e.id} value={e.id}>
{e.code} · {e.description}
</option>
))}
</select>
</label>
)}
<label>
Preço estimado (MZN), se não existir no acordo
<input
@@ -286,14 +381,82 @@ export default function Replenishment(props: Props) {
<RuleForm props={props} />
</Panel>
)}
<Panel title="Verificação e recuperação da reposição">
<p>
{props.recoveryReady
? 'Recuperação durável instalada. A verificação periódica depende do serviço de agendamento em execução.'
: 'Instalação da recuperação durável pendente.'}
</p>
{props.recoveryReady && (
<p>
Intervalo de verificação por artigo/armazém:{' '}
{props.scanMinutes} minutos. Serviço:{' '}
{props.schedulerStatus
? `${!props.schedulerRecent ? 'sem passagem recente confirmada; último registo' : props.schedulerStatus.result === 'passed' ? 'última passagem concluída' : 'recuperação pendente'} em ${props.schedulerStatus.finished_at}`
: 'sem confirmação recente nesta interface'}
.
</p>
)}
{props.work && (
<p>
Trabalhos acompanhados: {props.work.total} ·
Sinais pendentes: {props.work.pending ?? 0}{' '}
· A recuperar: {props.work.failed ?? 0} ·
Última verificação:{' '}
{props.work.last_run_at ??
'Ainda sem execução registada'}
</p>
)}
</Panel>
{props.workItems && props.workItems.total > 0 && (
<Panel title="Verificações pendentes e interrompidas">
{props.workItems.data.map((w) => (
<div
key={w.id}
className="mb-3 rounded border p-3"
>
<p>
{w.code} · {w.name} · {w.warehouse}
</p>
<p>
{w.last_error ??
'Alteração registada; avaliação pendente.'}{' '}
Próxima tentativa: {w.due_at}
</p>
{(props.canPrepare ||
props.canReview) && (
<Button
variant="outline"
onClick={() =>
router.post(
`/stock/reposicao/trabalhos/${w.id}/avaliar`,
{},
{
preserveState:
'errors',
},
)
}
>
Reavaliar agora
</Button>
)}
</div>
))}
<Pagination
page={props.workItems}
label="Páginas dos trabalhos pendentes"
/>
</Panel>
)}
<Panel title="Parametrizações">
{props.rules.length === 0 ? (
{(props.rules?.data.length ?? 0) === 0 ? (
<p>
Sem parametrizações. Não são assumidos
requisitantes ou centros de custo.
</p>
) : (
props.rules.map((r) => (
props.rules?.data.map((r) => (
<div
className="mb-4 rounded border p-4"
key={r.id}
@@ -345,22 +508,55 @@ export default function Replenishment(props: Props) {
</div>
))
)}
<Pagination
page={props.rules}
label="Páginas das parametrizações"
/>
</Panel>
<Panel title="Necessidades e compras associadas">
{props.needs.length === 0 ? (
<label>
Estado
<select
className={controlClass}
value={props.filters.need_state}
onChange={(e) =>
router.get(
'/stock/reposicao',
{ need_state: e.target.value },
{ preserveScroll: true },
)
}
>
<option value="all">
Todos · pendências primeiro
</option>
{[
'pending',
'created',
'covered',
'satisfied',
].map((s) => (
<option key={s} value={s}>
{states[s]}
</option>
))}
</select>
</label>
{(props.needs?.data.length ?? 0) === 0 ? (
<p>
Sem necessidades registadas após alterações
de stock ou activação de regras.
</p>
) : (
props.needs.map((n) => (
props.needs?.data.map((n) => (
<div
key={n.id}
className="mb-4 rounded border p-4"
>
<p className="font-medium">
Artigo #{n.snapshot.item.id} ·
Armazém #{n.snapshot.warehouse.id} ·{' '}
{n.snapshot.item.code} ·{' '}
{n.snapshot.item.name} ·
{n.snapshot.warehouse.name} ·{' '}
{states[n.state]}
</p>
<p>{n.data.message}</p>
@@ -412,19 +608,27 @@ export default function Replenishment(props: Props) {
</div>
))
)}
<Pagination
page={props.needs}
label="Páginas das necessidades"
/>
</Panel>
<Panel title="Histórico">
<details>
<summary>
Consultar versões e fundamentos
</summary>
{props.history.map((h) => (
{props.history?.data.map((h) => (
<p key={h.id} className="mt-2">
#{h.id} · {h.created_at} · Versão{' '}
{h.version} · {states[h.state]} ·{' '}
{h.reason}
</p>
))}
<Pagination
page={props.history}
label="Páginas do histórico"
/>
</details>
</Panel>
</>
+299 -2
View File
@@ -6,6 +6,7 @@ import {
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import type { WarehouseHealth } from '@/types/inventory';
type Line = {
item: { code: string; name: string; unit: string };
quantity_milli: number;
@@ -30,6 +31,45 @@ type Event = {
};
};
type Props = {
canViewReturns?: boolean;
supplyTrace: {
ready: boolean;
groups: {
item: { id: number; code: string; name: string; unit: string };
line_indexes: number[];
origin_need_id: number;
current_need_id: number;
need_state: string;
message: string;
unreserved_milli: number;
restricted: boolean;
purchases: {
id: number;
number: string;
status: string;
href: string;
next_task: string | null;
orders: {
id: number;
number: string;
href: string | null;
effective_received_milli: number;
receipts: {
id: number;
item_id: number;
received_on: string;
accepted_milli: number;
reversed: boolean;
}[];
}[];
}[];
}[];
};
health: WarehouseHealth;
people: Record<
'requester' | 'review' | 'delivery',
{ id: number; name: string }[]
>;
record: {
id: number;
snapshot: {
@@ -99,7 +139,10 @@ function Command({
.filter((line) => line.remaining > 0),
});
return (
<details className="rounded border p-3 print:hidden">
<details
id={`warehouse-command-${action}${delivery === undefined ? '' : `-${delivery}`}`}
className="rounded border p-3 print:hidden"
>
<summary className="cursor-pointer font-medium">
{labels[action]}
</summary>
@@ -163,6 +206,8 @@ function Command({
.code
}{' '}
· Pendente {line.remaining / 1000}{' '}
{props.progress.reservation_ready &&
`· Reservado ${props.progress.reserved[line.index] / 1000} `}
{
props.record.snapshot.lines[line.index].item
.unit
@@ -220,6 +265,7 @@ export default function WarehouseRequest(props: Props) {
description={`Estado: ${labels[p.state] ?? p.state}`}
/>
<div className="flex gap-4 print:hidden">
{props.canViewReturns && <Link href={`/stock/devolucoes-internas?request_id=${r.id}`}>Conferir devolução de excedentes</Link>}
<Link href="/stock/requisicoes">
Requisições ao armazém
</Link>
@@ -237,6 +283,204 @@ export default function WarehouseRequest(props: Props) {
<p>Local de utilização: {r.snapshot.destination}</p>
<p>Finalidade: {r.snapshot.reason}</p>
</Panel>
{props.supplyTrace.ready &&
(props.supplyTrace.groups.length > 0 ||
props.health.needs_reservation) && (
<div className="print:hidden">
<Panel title="Reposição e compras associadas">
<p className="text-sm text-muted-foreground">
A reposição serve o artigo neste armazém.
Após uma entrada, o autorizador confere o
stock utilizável e actualiza a reserva do
pedido; a saída acontece na entrega.
</p>
{props.canManageReservation &&
props.supplyTrace.groups.some(
(group) => group.unreserved_milli > 0,
) && (
<Button
className="mt-3"
variant="outline"
onClick={() => {
const command =
document.getElementById(
'warehouse-command-reserve',
);
if (
command instanceof
HTMLDetailsElement
) {
command.open = true;
command.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
}
}}
>
Conferir e actualizar reserva
</Button>
)}
{props.supplyTrace.groups.length === 0 && (
<p className="mt-3">
Ainda não existe ligação de reposição
registada para este pedido. A avaliação
depende da parametrização e das
necessidades detectadas.
</p>
)}
{props.supplyTrace.groups.map((group) => (
<section
key={group.origin_need_id}
className="mt-4 rounded-lg border p-4"
>
<h3 className="font-medium">
{group.item.code} ·{' '}
{group.item.name}
</h3>
<p className="text-sm">
Saldo deste pedido sem reserva:{' '}
{group.unreserved_milli / 1000}{' '}
{group.item.unit}. Necessidade de
origem #{group.origin_need_id};
avaliação actual #
{group.current_need_id}.
</p>
<p className="mt-2 text-sm">
{group.message}
</p>
{group.restricted && (
<p className="mt-2 text-sm text-muted-foreground">
O detalhe de algumas compras
exige acesso ao respectivo
processo.
</p>
)}
{group.purchases.map((purchase) => (
<div
key={purchase.id}
className="mt-3 border-t pt-3"
>
<p>
<Link
href={purchase.href}
className="underline"
>
Compra {purchase.number}
</Link>{' '}
·{' '}
{(
{
draft: 'Rascunho a conferir e submeter',
pending:
'Em aprovação',
approved:
'Aprovada',
rejected:
'Rejeitada',
} as Record<
string,
string
>
)[purchase.status] ??
purchase.status}
</p>
{purchase.next_task && (
<p className="text-sm">
Etapa pendente:{' '}
{purchase.next_task}
</p>
)}
{purchase.orders.length ===
0 && (
<p className="text-sm text-muted-foreground">
Sem encomenda emitida
neste processo.
</p>
)}
{purchase.orders.map(
(order) => (
<div
key={order.id}
className="mt-2 text-sm"
>
<p>
Encomenda{' '}
{order.href ? (
<Link
href={
order.href
}
className="underline"
>
{
order.number
}
</Link>
) : (
order.number
)}{' '}
· Entradas
aceites
efectivas neste
armazém:{' '}
{order.effective_received_milli /
1000}{' '}
{
group.item
.unit
}
</p>
{order.receipts
.length ===
0 && (
<p className="text-muted-foreground">
Sem entrada
deste artigo
registada no
destino.
</p>
)}
{order.receipts.map(
(receipt) => (
<p
key={
receipt.item_id
}
>
Recebimento
#
{
receipt.id
}{' '}
·{' '}
{
receipt.received_on
}{' '}
·{' '}
{receipt.accepted_milli /
1000}{' '}
{
group
.item
.unit
}
{receipt.reversed
? ' · Estornado'
: ' · Aceite'}
</p>
),
)}
</div>
),
)}
</div>
))}
</section>
))}
</Panel>
</div>
)}
<Panel title="Artigos e atendimento">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
@@ -299,7 +543,60 @@ export default function WarehouseRequest(props: Props) {
? 'A autorização reserva o material utilizável disponível. Só a entrega confirmada reduz o stock. Retenções de qualidade e validade são revalidadas na entrega.'
: 'Reserva de stock por instalar. A disponibilidade é revalidada na entrega.'}
</p>
{props.health.lines.some((l) => l.attention) && (
<div className="mt-3 space-y-2 rounded border border-amber-400 bg-amber-50 p-3 text-sm text-amber-950">
{props.health.lines
.filter((l) => l.attention)
.map((l) => (
<p key={l.index}>
{r.snapshot.lines[l.index].item.code} ·{' '}
{l.attention}
{l.unreserved_milli > 0
? ` Saldo sem reserva: ${l.unreserved_milli / 1000} ${r.snapshot.lines[l.index].item.unit}.`
: ''}
</p>
))}
</div>
)}
{props.health.unconfirmed_deliveries > 0 && (
<p className="mt-3 text-sm">
{props.health.unconfirmed_deliveries} entrega(s)
aguardam confirmação de recepção pelo requisitante.
Confirmar não gera nova saída.
</p>
)}
</Panel>
<div className="print:hidden">
<Panel title="Pessoas actualmente elegíveis para as tarefas">
<p className="mb-3 text-sm">
Conforme perfis, contexto, departamento e
independência actuais. Esta consulta não atribui
tarefas nem altera decisões anteriores.
</p>
{(
[
[
'requester',
'Requisição e confirmação de recepção',
],
[
'review',
'Autorização e acompanhamento da reserva',
],
['delivery', 'Entrega pelo armazém'],
] as const
).map(([key, label]) => (
<p key={key} className="mb-2">
<strong>{label}:</strong>{' '}
{props.people[key].length
? props.people[key]
.map((u) => u.name)
.join(', ')
: 'Sem pessoa elegível nas competências actualmente atribuídas; requer parametrização.'}
</p>
))}
</Panel>
</div>
{!props.ready ? (
<Panel title="Instalação pendente">
<p>Autorização e entrega ainda não instaladas.</p>
@@ -327,7 +624,7 @@ export default function WarehouseRequest(props: Props) {
/>
</>
)}
{props.canDeliver && (
{props.canDeliver && props.health.delivery_ready && (
<Command
key={`deliver:${p.version}`}
props={props}
+440 -230
View File
@@ -6,6 +6,11 @@ import {
controlClass,
} from '@/components/procurement';
import { Button } from '@/components/ui/button';
import {
warehouseStateLabels,
type WarehouseHealth,
type WarehouseActions,
} from '@/types/inventory';
type Item = { id: number; code: string; name: string; unit: string };
type Line = {
stock_item_id: string;
@@ -29,6 +34,15 @@ type Snapshot = {
}[];
};
type Props = {
filters: {
view: string;
state: string;
search: string;
warehouse_id: number | null;
};
views: { key: string; label: string; total: number }[];
states: Record<string, number>;
checkedAt: string;
ready: boolean;
canPrepare: boolean;
items: Item[];
@@ -40,10 +54,13 @@ type Props = {
id: number;
status: string;
snapshot: Snapshot;
progress?: { state: string };
progress?: { state: string; reserved: number[] };
health: WarehouseHealth;
actions: WarehouseActions;
}[];
prev_page_url: string | null;
next_page_url: string | null;
total: number;
};
};
const blank = (): Line => ({
@@ -53,6 +70,22 @@ const blank = (): Line => ({
equipment_id: '',
});
export default function Requests(props: Props) {
const filters = useForm({
search: props.filters.search,
state: props.filters.state,
warehouse_id: props.filters.warehouse_id?.toString() ?? '',
view: props.filters.view,
});
const viewUrl = (view: string) => {
const query = new URLSearchParams({
view,
state: 'all',
search: props.filters.search,
});
if (props.filters.warehouse_id)
query.set('warehouse_id', props.filters.warehouse_id.toString());
return '/stock/requisicoes?' + query.toString();
};
const form = useForm({
operation_key: crypto.randomUUID(),
warehouse_id: '',
@@ -76,17 +109,108 @@ export default function Requests(props: Props) {
<PageTitle
eyebrow="Armazém e activos"
title="Requisições ao armazém"
description="Registo inicial com destino de utilização. Um rascunho não autoriza, reserva ou retira material."
description="Acompanhar autorizações, reservas, entregas e recepções conforme as suas tarefas."
/>
<p className="text-sm">
Uso interno sem factura: a requisição autorizada acompanha a
saída e as entregas. Reserva física exclusiva e geração
automática de compra por falta ainda estão em implementação.
A venda a terceiros pertence à composição SGF.
saída e as entregas. A autorização reserva o material
disponível. A compra automática por falta depende da
parametrização do artigo e armazém. Consulte a reposição
para acompanhar as necessidades e os rascunhos associados. A
venda a terceiros pertence à composição SGF.
</p>
<Link href="/stock/disponibilidade">
Consultar disponibilidade (conforme o seu acesso)
</Link>
<nav
aria-label="Tarefas do armazém"
className="flex flex-wrap gap-3"
>
{props.views.map((v) => (
<Link
key={v.key}
href={viewUrl(v.key)}
className={`rounded border px-4 py-3 ${props.filters.view === v.key ? 'bg-teal-50 font-semibold text-teal-950' : ''}`}
>
{v.label} · {v.total}
</Link>
))}
</nav>
<Panel title="Pesquisar e filtrar acompanhamento">
<form
className="grid gap-3 md:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
filters.get('/stock/requisicoes', {
preserveState: true,
preserveScroll: true,
});
}}
>
<label>
Referência, requisitante ou local
<input
className={controlClass}
value={filters.data.search}
onChange={(e) =>
filters.setData('search', e.target.value)
}
placeholder="RA-123 ou local de utilização"
/>
</label>
<label>
Armazém
<select
className={controlClass}
value={filters.data.warehouse_id}
onChange={(e) =>
filters.setData(
'warehouse_id',
e.target.value,
)
}
>
<option value="">Todos no meu âmbito</option>
{props.warehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.code} · {w.name}
</option>
))}
</select>
</label>
<label>
Estado
<select
className={controlClass}
value={filters.data.state}
onChange={(e) =>
filters.setData('state', e.target.value)
}
>
<option value="all">Todos</option>
{Object.entries(warehouseStateLabels).map(
([key, label]) => (
<option key={key} value={key}>
{label} · {props.states[key] ?? 0}
</option>
),
)}
</select>
</label>
<Button
className="self-end"
disabled={filters.processing}
>
Aplicar filtros
</Button>
</form>
<FormErrors errors={filters.errors} />
<p className="mt-3 text-sm">
{props.records?.total ?? 0} requisições nos filtros ·
Ordem de registo, mais antigas primeiro. Conferência:{' '}
{new Date(props.checkedAt).toLocaleString('pt-PT')}.
</p>
</Panel>
{!props.ready ? (
<Panel title="Instalação pendente">
<p>
@@ -95,265 +219,292 @@ export default function Requests(props: Props) {
</Panel>
) : (
props.canPrepare && (
<Panel title="Preparar rascunho">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/requisicoes', {
preserveState: 'errors',
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<label>
Armazém
<select
required
className={controlClass}
value={form.data.warehouse_id}
onChange={(e) =>
form.setData(
'warehouse_id',
e.target.value,
)
}
>
<option value="">Seleccione</option>
{props.warehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.code} · {w.name}
</option>
))}
</select>
</label>
<label>
Centro de custo
<select
required
className={controlClass}
value={form.data.cost_center_id}
onChange={(e) =>
form.setData(
'cost_center_id',
e.target.value,
)
}
>
<option value="">
Seleccione do seu departamento
</option>
{props.costCenters.map((c) => (
<option key={c.id} value={c.id}>
{c.code} · {c.name}
</option>
))}
</select>
</label>
<label>
Onde será utilizado
<input
required
className={controlClass}
value={form.data.destination}
onChange={(e) =>
form.setData(
'destination',
e.target.value,
)
}
/>
</label>
<label>
Prioridade
<select
className={controlClass}
value={form.data.priority}
onChange={(e) =>
form.setData(
'priority',
e.target.value,
)
}
>
<option value="normal">
Normal
</option>
<option value="urgent">
Urgente
</option>
</select>
</label>
</div>
<label className="block">
Finalidade/justificação
<textarea
required
minLength={10}
className={controlClass}
value={form.data.reason}
onChange={(e) =>
form.setData(
'reason',
e.target.value,
)
}
/>
</label>
{form.data.lines.map((row, index) => (
<fieldset
key={index}
className="grid gap-3 rounded border p-3 md:grid-cols-2"
>
<legend>Artigo {index + 1}</legend>
<details
open={
Object.keys(form.errors).length > 0
? true
: undefined
}
>
<summary className="cursor-pointer font-medium">
Preparar nova requisição
</summary>
<Panel title="Preparar rascunho">
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.post('/stock/requisicoes', {
preserveState: 'errors',
});
}}
>
<FormErrors errors={form.errors} />
<div className="grid gap-4 md:grid-cols-2">
<label>
Artigo do cadastro
Armazém
<select
required
className={controlClass}
value={row.stock_item_id}
value={form.data.warehouse_id}
onChange={(e) =>
line(index, {
stock_item_id:
e.target.value,
})
form.setData(
'warehouse_id',
e.target.value,
)
}
>
<option value="">
Seleccione
</option>
{props.items.map((item) => (
{props.warehouses.map((w) => (
<option
key={item.id}
value={item.id}
key={w.id}
value={w.id}
>
{item.code} ·{' '}
{item.name} ({item.unit}
)
{w.code} · {w.name}
</option>
))}
</select>
</label>
<label>
Quantidade
Centro de custo
<select
required
className={controlClass}
value={form.data.cost_center_id}
onChange={(e) =>
form.setData(
'cost_center_id',
e.target.value,
)
}
>
<option value="">
Seleccione do seu
departamento
</option>
{props.costCenters.map((c) => (
<option
key={c.id}
value={c.id}
>
{c.code} · {c.name}
</option>
))}
</select>
</label>
<label>
Onde será utilizado
<input
required
className={controlClass}
inputMode="decimal"
placeholder="Ex.: 2.500"
value={row.quantity}
value={form.data.destination}
onChange={(e) =>
line(index, {
quantity:
e.target.value,
})
form.setData(
'destination',
e.target.value,
)
}
/>
</label>
<label>
Utilização
Prioridade
<select
className={controlClass}
value={row.use}
value={form.data.priority}
onChange={(e) =>
line(index, {
use: e.target.value,
equipment_id: '',
})
form.setData(
'priority',
e.target.value,
)
}
>
<option value="internal">
Uso interno no local
indicado
<option value="normal">
Normal
</option>
<option value="equipment">
Em equipamento
<option value="urgent">
Urgente
</option>
</select>
</label>
{row.use === 'equipment' && (
</div>
<label className="block">
Finalidade/justificação
<textarea
required
minLength={10}
className={controlClass}
value={form.data.reason}
onChange={(e) =>
form.setData(
'reason',
e.target.value,
)
}
/>
</label>
{form.data.lines.map((row, index) => (
<fieldset
key={index}
className="grid gap-3 rounded border p-3 md:grid-cols-2"
>
<legend>Artigo {index + 1}</legend>
<label>
Equipamento de destino
Artigo do cadastro
<select
required
className={controlClass}
value={row.equipment_id}
value={row.stock_item_id}
onChange={(e) =>
line(index, {
equipment_id:
stock_item_id:
e.target.value,
})
}
>
<option value="">
Seleccione do seu
departamento
Seleccione
</option>
{props.equipment.map(
(eq) => (
<option
key={eq.id}
value={eq.id}
>
{eq.code} ·{' '}
{eq.description}
</option>
),
)}
{props.items.map((item) => (
<option
key={item.id}
value={item.id}
>
{item.code} ·{' '}
{item.name} (
{item.unit})
</option>
))}
</select>
</label>
)}
{form.data.lines.length > 1 && (
<Button
type="button"
variant="outline"
onClick={() =>
form.setData(
'lines',
form.data.lines.filter(
(_, i) =>
i !== index,
),
)
}
>
Retirar linha
</Button>
)}
</fieldset>
))}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
disabled={form.data.lines.length >= 50}
onClick={() =>
form.setData('lines', [
...form.data.lines,
blank(),
])
}
>
Acrescentar artigo
</Button>
<Button
disabled={
form.processing ||
props.costCenters.length === 0
}
>
Guardar rascunho
</Button>
</div>
{props.costCenters.length === 0 && (
<p>
É necessário um centro de custo
associado ao seu departamento.
</p>
)}
</form>
</Panel>
<label>
Quantidade
<input
required
className={controlClass}
inputMode="decimal"
placeholder="Ex.: 2.500"
value={row.quantity}
onChange={(e) =>
line(index, {
quantity:
e.target.value,
})
}
/>
</label>
<label>
Utilização
<select
className={controlClass}
value={row.use}
onChange={(e) =>
line(index, {
use: e.target.value,
equipment_id: '',
})
}
>
<option value="internal">
Uso interno no local
indicado
</option>
<option value="equipment">
Em equipamento
</option>
</select>
</label>
{row.use === 'equipment' && (
<label>
Equipamento de destino
<select
required
className={controlClass}
value={row.equipment_id}
onChange={(e) =>
line(index, {
equipment_id:
e.target
.value,
})
}
>
<option value="">
Seleccione do seu
departamento
</option>
{props.equipment.map(
(eq) => (
<option
key={eq.id}
value={
eq.id
}
>
{eq.code} ·{' '}
{
eq.description
}
</option>
),
)}
</select>
</label>
)}
{form.data.lines.length > 1 && (
<Button
type="button"
variant="outline"
onClick={() =>
form.setData(
'lines',
form.data.lines.filter(
(_, i) =>
i !== index,
),
)
}
>
Retirar linha
</Button>
)}
</fieldset>
))}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
disabled={
form.data.lines.length >= 50
}
onClick={() =>
form.setData('lines', [
...form.data.lines,
blank(),
])
}
>
Acrescentar artigo
</Button>
<Button
disabled={
form.processing ||
props.costCenters.length === 0
}
>
Guardar rascunho
</Button>
</div>
{props.costCenters.length === 0 && (
<p>
É necessário um centro de custo
associado ao seu departamento.
</p>
)}
</form>
</Panel>
</details>
)
)}
<Panel title="Requisições registadas">
@@ -368,18 +519,9 @@ export default function Requests(props: Props) {
<summary className="cursor-pointer font-medium">
RA-{record.id} ·{' '}
{
(
{
draft: 'Rascunho',
submitted: 'Submetida',
authorized: 'Autorizada',
partial:
'Parcialmente entregue',
delivered: 'Entregue',
closed: 'Encerrada',
rejected: 'Rejeitada',
} as Record<string, string>
)[record.progress?.state ?? 'draft']
warehouseStateLabels[
record.progress?.state ?? 'draft'
]
}{' '}
· {record.snapshot.requester.name} ·{' '}
{record.snapshot.warehouse.name}
@@ -389,8 +531,8 @@ export default function Requests(props: Props) {
className="underline"
href={`/stock/requisicoes/${record.id}`}
>
Abrir requisição, autorizar ou registar
entrega
Consultar requisição e tarefas
disponíveis
</Link>
<p>
Destino: {record.snapshot.destination}
@@ -401,6 +543,44 @@ export default function Requests(props: Props) {
{record.snapshot.cost_center.name}
</p>
<p>Finalidade: {record.snapshot.reason}</p>
{record.actions.canReview && (
<p>
Para si: autorizar ou rejeitar o
pedido.
</p>
)}
{record.actions.canManageReservation && (
<p>
Para si: acompanhar/actualizar a
reserva ou cancelar
fundamentadamente o saldo.
</p>
)}
{record.actions.canDeliver && (
<p>
{record.health.delivery_ready
? 'Para si: há material reservado para registar a entrega efectiva.'
: 'Entrega pendente: conferir reserva, disponibilidade e destino antes de retirar material.'}
</p>
)}
{record.actions.canReceive &&
record.health.unconfirmed_deliveries >
0 && (
<p>
Para si: confirmar{' '}
{
record.health
.unconfirmed_deliveries
}{' '}
entrega(s) recebida(s).
</p>
)}
{record.actions.canSubmit && (
<p>
Para si: conferir e submeter o
rascunho.
</p>
)}
{record.snapshot.lines.map(
(item, index) => (
<p key={index}>
@@ -411,6 +591,36 @@ export default function Requests(props: Props) {
{item.equipment
? `Equipamento ${item.equipment.code} · ${item.equipment.description}`
: item.destination}
{record.progress && (
<span>
{' '}
· Reservado{' '}
{record.progress
.reserved[index] /
1000}
</span>
)}
{record.health.lines[index]
?.unreserved_milli > 0 && (
<span>
{' '}
· Sem reserva{' '}
{record.health.lines[
index
].unreserved_milli /
1000}
</span>
)}
{record.health.lines[index]
?.attention && (
<span className="block text-amber-800">
{
record.health.lines[
index
].attention
}
</span>
)}
</p>
),
)}
+51
View File
@@ -0,0 +1,51 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { PageTitle, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Impact = { physical_before: string; physical_after: string; return_quantity_milli: string; not_returned_before: string; not_returned_after: string; original_movement_id: number; item: { code: string; name: string; unit: string }; warehouse: { code: string; name: string } };
type Page = { page: number; last_page: number; total: number };
type Row = { id: number; prepared_id: number; version: number; state: string; reason: string; evidence: string; preparer_id: number; author_id: number; created_at: string; impact: Impact };
type Option = { delivery_event_id: number; line_index: number; delivered_milli: string; item: Impact['item'] };
const milli = (value: string) => { const q = BigInt(value); return `${q / 1000n},${(q % 1000n).toString().padStart(3, '0')}`; };
const states: Record<string,string> = {prepared:'Por confirmar',confirmed:'Confirmada',withdrawn:'Retirada',rejected:'Recusada'};
export function WarehouseReturnImpact({ impact: p }: { impact: Impact }) {
return <p>Stock físico: {milli(p.physical_before)} → {milli(p.physical_after)}. Devolução: {milli(p.return_quantity_milli)} {p.item.unit}. Entrega ainda não devolvida: {milli(p.not_returned_before)} → {milli(p.not_returned_after)}. {p.item.code} · {p.warehouse.code} · Saída original #{p.original_movement_id}.</p>;
}
async function api<T>(url: string, body?: object, signal?: AbortSignal): Promise<T> {
const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.slice(11);
const response = await fetch(url, { method: body ? 'POST' : 'GET', credentials: 'same-origin', signal, headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', ...(token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
const result = await response.json(); if (!response.ok) throw new Error(result.errors ? Object.values(result.errors).flat().join(' ') : result.message || `Pedido recusado (${response.status})`); return result;
}
export default function WarehouseReturns({ requestId, actorId, canPrepare, canConfirm, canWithdraw=false }: { requestId: number | null; actorId: number; canPrepare: boolean; canConfirm: boolean; canWithdraw?:boolean }) {
const [history, setHistory] = useState<{ rows: Row[]; pagination: Page } | null>(null), [sources, setSources] = useState<{ options: Option[]; pagination: Page } | null>(null);
const [page, setPage] = useState(1), [sourcePage, setSourcePage] = useState(1), [state, setState] = useState('all'), [reload, setReload] = useState(0), [error, setError] = useState(''), [busy, setBusy] = useState(false), [preview, setPreview] = useState<{ id: number; impact: Impact; state?:string } | null>(null);
const generation = useRef(0), command = useRef<{ data: string; key: string } | null>(null);
useEffect(() => { const n = ++generation.current, abort = new AbortController(); setHistory(null); setSources(null); setError('');
Promise.all([api<{ rows: Row[]; pagination: Page }>(`/api/stock-returns?page=${page}&state=${state}`, undefined, abort.signal), requestId ? api<{ options: Option[]; pagination: Page }>(`/api/stock-returns/sources?request_id=${requestId}&source_page=${sourcePage}`, undefined, abort.signal) : Promise.resolve(null)])
.then(([h, s]) => { if (n === generation.current) { setHistory(h); setSources(s); } }).catch((e: Error) => { if (!abort.signal.aborted && n === generation.current) setError(e.message); });
return () => { generation.current++; abort.abort(); };
}, [requestId, actorId, page, sourcePage, state, reload]);
useEffect(() => { setPreview(null); command.current = null; setPage(1); setSourcePage(1); }, [requestId, actorId]);
async function prepare(e: FormEvent<HTMLFormElement>) { e.preventDefault(); const n = generation.current; setBusy(true); setError(''); const fields = Object.fromEntries(new FormData(e.currentTarget)), choice = sources?.options.find((o) => `${o.delivery_event_id}:${o.line_index}` === fields.source);
if (!choice) { setError('Seleccione uma entrega recebida.'); setBusy(false); return; }
const body = { delivery_event_id: choice.delivery_event_id, line_index: choice.line_index, quantity: fields.quantity, declared_unused: fields.declared_unused === 'on', reason: fields.reason, evidence: fields.evidence }, data = JSON.stringify(body);
if (command.current?.data !== data) command.current = { data, key: crypto.randomUUID() };
try { const p = await api<{ id: number; impact: Impact }>('/api/stock-returns/prepare', { ...body, operation_key: command.current.key }); if (n === generation.current) { setPreview(p); setReload((x) => x + 1); } } catch (err) { if (n === generation.current) setError((err as Error).message); } finally { setBusy(false); }
}
async function confirm(e: FormEvent<HTMLFormElement>, row: Row) { e.preventDefault(); const n = generation.current; setBusy(true); setError(''); const fields = Object.fromEntries(new FormData(e.currentTarget));
try { await api(`/api/stock-returns/${row.prepared_id}/confirm`, { version: 1, received_in_warehouse: fields.received_in_warehouse === 'on', reason: fields.reason }); if (n === generation.current) { setPreview(null); setReload((x) => x + 1); } } catch (err) { if (n === generation.current) setError((err as Error).message); } finally { setBusy(false); }
}
async function close(e:FormEvent<HTMLFormElement>,row:Row,action:'withdraw'|'reject'){e.preventDefault();const n=generation.current;setBusy(true);setError('');const fields=Object.fromEntries(new FormData(e.currentTarget));try{await api(`/api/stock-returns/${row.prepared_id}/${action}`,{version:1,reason:fields.reason});if(n===generation.current){setPreview(null);setReload(x=>x+1);}}catch(err){if(n===generation.current)setError((err as Error).message);}finally{setBusy(false);}}
return <><Head title="Devoluções de excedentes" /><div className="space-y-6 p-6"><PageTitle eyebrow="Armazém · Material não consumido" title="Devoluções de excedentes" description="Declaração do destinatário e recepção física independente. O original permanece intacto." />
<Link href={requestId ? `/stock/requisicoes/${requestId}` : '/dashboard'}>{requestId ? 'Consultar a requisição de origem' : 'Voltar ao Painel Inicial'}</Link>
{error && <p role="alert">{error}</p>}<Button disabled={busy} onClick={() => setReload((x) => x + 1)}>Actualizar</Button>
{canPrepare && requestId && sources && <Panel title={`Entregas recebidas · RA-${requestId}`}><form onSubmit={prepare} className="space-y-4"><label>Entrega e linha de origem<select name="source" className={controlClass} defaultValue="" required><option value="" disabled>Seleccione a origem</option>{sources.options.map((o) => <option key={`${o.delivery_event_id}:${o.line_index}`} value={`${o.delivery_event_id}:${o.line_index}`}>Entrega #{o.delivery_event_id} · {o.item.code} · {o.item.name} · entregue {milli(o.delivered_milli)} {o.item.unit}</option>)}</select></label>
<label>Quantidade efectivamente devolvida<input name="quantity" className={controlClass} pattern="[0-9]{1,6}(\.[0-9]{1,3})?" required /></label><label>Evidência de excedente e entrega física<textarea name="evidence" className={controlClass} minLength={10} maxLength={1000} required /></label><label>Fundamento<textarea name="reason" className={controlClass} minLength={10} maxLength={1000} required /></label><label><input type="checkbox" name="declared_unused" required />Declaro que esta quantidade não foi consumida</label><Button disabled={busy}>Conferir impacto</Button></form>
<Button disabled={sourcePage <= 1 || busy} onClick={() => setSourcePage((x) => x - 1)}>Entregas anteriores</Button><Button disabled={sourcePage >= sources.pagination.last_page || busy} onClick={() => setSourcePage((x) => x + 1)}>Entregas seguintes</Button></Panel>}
{preview && <Panel title={`Prévia da proposta #${preview.id}`}><WarehouseReturnImpact impact={preview.impact} /><p>{preview.state&&preview.state!=='prepared'?`Decisão já registada: ${states[preview.state]??'Arquivada'}. Sem novo efeito.`:'A preparação não altera stock. Aguarda outra identidade autorizada.'}</p></Panel>}
<Panel title="Histórico e decisões"><label>Estado<select className={controlClass} value={state} onChange={(e) => { setState(e.target.value); setPage(1); }}><option value="all">Todos</option><option value="prepared">Por confirmar</option><option value="confirmed">Confirmados</option><option value="withdrawn">Retirados</option><option value="rejected">Recusados</option></select></label>
{!history ? <p role="status">A consultar decisões…</p> : <>{history.rows.map((r) => <article key={r.id} className="space-y-3 border-b py-4"><h3>Proposta #{r.prepared_id} · v{r.version} · {states[r.state]??'Estado arquivado'}</h3><p>{r.state==="confirmed"?"Entrada efectuada":r.state==="prepared"?"Impacto proposto":"Prévia arquivada, não executada"}</p><WarehouseReturnImpact impact={r.impact} /><p>{r.reason} · Evidência: {r.evidence}</p><p>Preparação #{r.preparer_id} · Autor desta versão #{r.author_id} · {r.created_at}</p>
{r.state === 'prepared' && canConfirm && r.preparer_id !== actorId && <form onSubmit={(e) => confirm(e, r)}><label>Fundamento da recepção<textarea name="reason" className={controlClass} minLength={10} maxLength={1000} required /></label><label><input type="checkbox" name="received_in_warehouse" required />Recebi fisicamente esta quantidade no armazém indicado</label><Button disabled={busy}>Confirmar entrada conferida</Button></form>}<>{r.state==="prepared"&&canWithdraw&&r.preparer_id===actorId&&<details><summary>Retirar proposta sem movimento</summary><form onSubmit={e=>close(e,r,"withdraw")}><label>Fundamento da retirada<textarea name="reason" className={controlClass} minLength={10} maxLength={1000} required/></label><Button disabled={busy}>Retirar proposta</Button></form></details>}{r.state==="prepared"&&canConfirm&&r.preparer_id!==actorId&&<details><summary>Recusar recepção sem movimento</summary><form onSubmit={e=>close(e,r,"reject")}><label>Fundamento da recusa<textarea name="reason" className={controlClass} minLength={10} maxLength={1000} required/></label><Button disabled={busy}>Recusar conferência</Button></form></details>}</></article>)}{!history.rows.length && <p>Sem decisões neste filtro.</p>}<Button disabled={page <= 1 || busy} onClick={() => setPage((x) => x - 1)}>Anterior</Button><Button disabled={page >= history.pagination.last_page || busy} onClick={() => setPage((x) => x + 1)}>Seguinte</Button></>}
</Panel></div></>;
}
+188
View File
@@ -0,0 +1,188 @@
import { Head, Link, router } from '@inertiajs/react';
import { useState } from 'react';
import { PageTitle, Panel, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
type Props = {
area: 'procurement' | 'people';
modules: Record<string, string>;
counts: Record<string, number>;
checkedAt: string;
filters: { module: string; search: string };
tasks: {
total: number;
data: {
id: string;
module: string;
reference: string;
label: string;
blocked: boolean;
created_at: string | null;
due_on: string | null;
href: string;
}[];
prev_page_url: string | null;
next_page_url: string | null;
};
};
export default function Tasks({
area,
modules,
counts,
checkedAt,
filters,
tasks,
}: Props) {
const [search, setSearch] = useState(filters.search);
const [module, setModule] = useState(filters.module);
const path =
area === 'people' ? '/pessoas/tarefas' : '/requisicoes/tarefas';
return (
<>
<Head title="As minhas tarefas" />
<div className="space-y-6 p-6">
<PageTitle
eyebrow={
area === 'people'
? 'Pessoas e contratações'
: 'Compras, armazém e finanças'
}
title="As minhas tarefas"
description={
area === 'people'
? 'Intervenções atribuídas à sua equipa nos processos de Pessoas, com ligação ao formulário original.'
: 'Requisições, consulta ao mercado, avaliações de propostas, adjudicações, ordens, intervenções de armazém e conferências financeiras, com ligação ao formulário original.'
}
action={
<Button
variant="outline"
onClick={() => router.reload()}
>
Actualizar
</Button>
}
/>
<div className="flex flex-wrap gap-3">
{Object.entries(modules).map(([key, label]) => (
<Link
key={key}
href={`${path}?module=${key}`}
className="rounded-lg border px-4 py-3"
>
{label}: <strong>{counts[key] ?? 0}</strong>
</Link>
))}
</div>
<Panel title="Trabalho pendente">
<form
className="mb-4 flex flex-wrap items-end gap-3"
onSubmit={(event) => {
event.preventDefault();
router.get(
path,
{ module, search },
{ preserveState: true },
);
}}
>
<label>
Módulo
<select
className={controlClass}
value={module}
onChange={(event) =>
setModule(event.target.value)
}
>
<option value="all">Todos nesta área</option>
{Object.entries(modules).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<label>
Referência ou tarefa
<input
className={controlClass}
value={search}
maxLength={100}
onChange={(event) =>
setSearch(event.target.value)
}
/>
</label>
<Button type="submit" variant="outline">
Filtrar
</Button>
<Link href={path} className="text-sm underline">
Limpar filtros
</Link>
</form>
<p className="mb-4 text-sm text-muted-foreground">
{tasks.total} tarefa(s) no filtro. Documentos mais
antigos aparecem primeiro; esta ordem não define
prioridade de atendimento. Consulta actualizada em{' '}
{new Date(checkedAt).toLocaleString('pt-PT')}.
</p>
{tasks.data.length === 0 ? (
<p>
Sem intervenções pendentes neste âmbito. A consulta
administrativa, por si só, não atribui tarefas
operacionais.
</p>
) : (
<div className="space-y-3">
{tasks.data.map((task) => (
<article
key={task.id}
className="rounded-lg border p-4"
>
<p className="text-xs text-muted-foreground">
{modules[task.module]} ·{' '}
{task.reference}
</p>
<Link
href={task.href}
className="font-medium underline"
>
{task.label}
</Link>
{task.blocked && (
<p className="mt-2 text-sm">
A entrega aguarda conferência do
stock, qualidade ou reserva. Abra o
pedido para consultar o impedimento
e os responsáveis elegíveis.
</p>
)}
{task.due_on && (
<p className="mt-2 text-sm">
Prazo registado no processo:{' '}
{task.due_on}
</p>
)}
</article>
))}
</div>
)}
<div className="mt-4 flex gap-4">
{tasks.prev_page_url && (
<Link href={tasks.prev_page_url}>Anterior</Link>
)}
{tasks.next_page_url && (
<Link href={tasks.next_page_url}>Seguinte</Link>
)}
</div>
</Panel>
<p className="text-sm text-muted-foreground">
Abrir uma tarefa não decide nem conclui o processo. A lista
muda quando a intervenção é registada na origem ou quando
mudam as suas competências.
</p>
</div>
</>
);
}
+30
View File
@@ -96,3 +96,33 @@ export const qualityStates: Record<string, string> = {
reservations: 'Conforme com reservas',
nonconforming: 'Não conforme',
};
export type WarehouseHealth = {
lines: {
index: number;
unreserved_milli: number;
attention: string | null;
source_changed: boolean;
ready: boolean;
}[];
needs_reservation: boolean;
needs_check: boolean;
unconfirmed_deliveries: number;
delivery_ready: boolean;
};
export type WarehouseActions = {
canSubmit: boolean;
canReview: boolean;
canDeliver: boolean;
canManageReservation: boolean;
canReceive: boolean;
};
export const warehouseStateLabels: Record<string, string> = {
draft: 'Rascunho',
submitted: 'Submetida',
authorized: 'Autorizada',
partial: 'Parcialmente entregue',
delivered: 'Entregue',
closed: 'Encerrada',
rejected: 'Rejeitada',
cancelled: 'Saldo cancelado',
};