commit14
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user