@@ -36,8 +36,12 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const access = page.props.organizationAccess as {
|
||||
active: boolean;
|
||||
permissions: string[];
|
||||
} | null;
|
||||
const groups = procurementNavigation(
|
||||
user?.purchasing_role ?? '',
|
||||
access?.active ? access.permissions : (user?.purchasing_role ?? ''),
|
||||
page.props.payablesAvailable === true,
|
||||
);
|
||||
const activeId = activeProcurementEntry(groups, page.url);
|
||||
@@ -404,16 +408,18 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
{searchField()}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
|
||||
>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
{page.props.canRequest === true && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="hidden bg-[#ed1c24] text-white hover:bg-[#d01720] sm:inline-flex"
|
||||
>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{user && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -438,10 +444,23 @@ export function SgcWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{roleLabels[
|
||||
user.purchasing_role ??
|
||||
''
|
||||
] ?? 'Utilizador'}
|
||||
{(
|
||||
page.props
|
||||
.organizationAccess as {
|
||||
active: boolean;
|
||||
profileLabel: string;
|
||||
} | null
|
||||
)?.active
|
||||
? (
|
||||
page.props
|
||||
.organizationAccess as {
|
||||
profileLabel: string;
|
||||
}
|
||||
).profileLabel
|
||||
: (roleLabels[
|
||||
user.purchasing_role ??
|
||||
''
|
||||
] ?? 'Utilizador')}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { controlClass } from '@/components/procurement';
|
||||
|
||||
export type CriterionResult =
|
||||
| 'pending'
|
||||
| 'observed'
|
||||
| 'insufficient'
|
||||
| 'not_applicable';
|
||||
export type EvaluationPayload = {
|
||||
title: string;
|
||||
nature: string;
|
||||
scope: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
order_ids: number[];
|
||||
criteria: {
|
||||
label: string;
|
||||
result: CriterionResult;
|
||||
reason: string;
|
||||
evidence: string;
|
||||
}[];
|
||||
conclusion: string;
|
||||
critical_occurrences: string;
|
||||
critical_evidence: string;
|
||||
};
|
||||
export type EvaluationOrder = {
|
||||
id: number;
|
||||
number: string;
|
||||
supplier_id: number;
|
||||
};
|
||||
export const evaluationStates: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
review: 'Em revisão',
|
||||
returned: 'Devolvida para correcção',
|
||||
approved: 'Aprovada',
|
||||
};
|
||||
export const criterionStates: Record<CriterionResult, string> = {
|
||||
pending: 'Por analisar',
|
||||
observed: 'Observação fundamentada',
|
||||
insufficient: 'Evidência insuficiente',
|
||||
not_applicable: 'Não aplicável',
|
||||
};
|
||||
export const emptyEvaluation = (): EvaluationPayload => ({
|
||||
title: '',
|
||||
nature: '',
|
||||
scope: '',
|
||||
period_start: '',
|
||||
period_end: '',
|
||||
order_ids: [],
|
||||
criteria: [],
|
||||
conclusion: '',
|
||||
critical_occurrences: '',
|
||||
critical_evidence: '',
|
||||
});
|
||||
|
||||
export default function SupplierEvaluationFields({
|
||||
payload,
|
||||
orders,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
payload: EvaluationPayload;
|
||||
orders: EvaluationOrder[];
|
||||
disabled: boolean;
|
||||
onChange: (value: EvaluationPayload) => void;
|
||||
}) {
|
||||
const set = <K extends keyof EvaluationPayload>(
|
||||
key: K,
|
||||
value: EvaluationPayload[K],
|
||||
) => onChange({ ...payload, [key]: value });
|
||||
const criterion = (
|
||||
index: number,
|
||||
patch: Partial<EvaluationPayload['criteria'][number]>,
|
||||
) =>
|
||||
set(
|
||||
'criteria',
|
||||
payload.criteria.map((row, i) =>
|
||||
i === index ? { ...row, ...patch } : row,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<fieldset disabled={disabled} className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-1 text-sm">
|
||||
Título
|
||||
<input
|
||||
className={controlClass}
|
||||
value={payload.title}
|
||||
maxLength={200}
|
||||
onChange={(event) => set('title', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Componente avaliada
|
||||
<select
|
||||
className={controlClass}
|
||||
value={payload.nature}
|
||||
onChange={(event) => set('nature', event.target.value)}
|
||||
>
|
||||
<option value="">Escolher</option>
|
||||
<option value="goods">Bens</option>
|
||||
<option value="services">Serviços</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Período — início
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={payload.period_start ?? ''}
|
||||
onChange={(event) =>
|
||||
set('period_start', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Período — fim
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={payload.period_end ?? ''}
|
||||
onChange={(event) =>
|
||||
set('period_end', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Âmbito, locais, entregas e exclusões justificadas
|
||||
<textarea
|
||||
className={controlClass + ' h-auto min-h-24 py-2'}
|
||||
value={payload.scope}
|
||||
maxLength={10000}
|
||||
onChange={(event) => set('scope', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<details className="rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Ordens abrangidas ({payload.order_ids.length})
|
||||
</summary>
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
Seleccione apenas as ordens efectivamente abrangidas. Outras
|
||||
referências contratuais podem ser descritas no âmbito.
|
||||
</p>
|
||||
<div className="max-h-56 space-y-2 overflow-y-auto">
|
||||
{orders.map((order) => (
|
||||
<label
|
||||
key={order.id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={payload.order_ids.includes(order.id)}
|
||||
onChange={(event) =>
|
||||
set(
|
||||
'order_ids',
|
||||
event.target.checked
|
||||
? [...payload.order_ids, order.id]
|
||||
: payload.order_ids.filter(
|
||||
(id) => id !== order.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{order.number}
|
||||
</label>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<p className="text-sm">
|
||||
Sem ordens autorizadas disponíveis para este
|
||||
fornecedor.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<section className="space-y-4" aria-label="Critérios e evidências">
|
||||
<h3 className="font-medium">Critérios e evidências</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registe o cumprimento da entidade fornecedora, com os
|
||||
requisitos acordados e referências recuperáveis. Falta de
|
||||
evidência permanece explícita; não recebe uma nota mínima.
|
||||
Este ciclo é qualitativo, sem pesos ou classificações
|
||||
automáticas.
|
||||
</p>
|
||||
{payload.criteria.map((row, index) => (
|
||||
<fieldset
|
||||
key={index}
|
||||
className="space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<legend className="px-2 text-sm font-medium">
|
||||
Critério {index + 1}
|
||||
</legend>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Critério e condição verificável
|
||||
<input
|
||||
className={controlClass}
|
||||
value={row.label}
|
||||
maxLength={300}
|
||||
onChange={(event) =>
|
||||
criterion(index, {
|
||||
label: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Situação
|
||||
<select
|
||||
className={controlClass}
|
||||
value={row.result}
|
||||
onChange={(event) =>
|
||||
criterion(index, {
|
||||
result: event.target
|
||||
.value as CriterionResult,
|
||||
})
|
||||
}
|
||||
>
|
||||
{Object.entries(criterionStates).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Justificação, limites e atribuição das ocorrências
|
||||
<textarea
|
||||
className={
|
||||
controlClass + ' h-auto min-h-20 py-2'
|
||||
}
|
||||
value={row.reason}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
criterion(index, {
|
||||
reason: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência de evidência (recepção, inspecção,
|
||||
documento, comunicação)
|
||||
<textarea
|
||||
className={
|
||||
controlClass + ' h-auto min-h-20 py-2'
|
||||
}
|
||||
value={row.evidence}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
criterion(index, {
|
||||
evidence: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
set(
|
||||
'criteria',
|
||||
payload.criteria.filter(
|
||||
(_, i) => i !== index,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Retirar este critério do rascunho
|
||||
</Button>
|
||||
</fieldset>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={payload.criteria.length >= 30}
|
||||
onClick={() =>
|
||||
set('criteria', [
|
||||
...payload.criteria,
|
||||
{
|
||||
label: '',
|
||||
result: 'pending',
|
||||
reason: '',
|
||||
evidence: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Acrescentar critério
|
||||
</Button>
|
||||
</section>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Conclusão e encaminhamento
|
||||
<textarea
|
||||
className={controlClass + ' h-auto min-h-24 py-2'}
|
||||
value={payload.conclusion}
|
||||
maxLength={10000}
|
||||
onChange={(event) => set('conclusion', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<details className="space-y-3 rounded-lg border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Ocorrências críticas
|
||||
</summary>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Ocorrências e impacto documentado
|
||||
<textarea
|
||||
className={controlClass + ' h-auto min-h-20 py-2'}
|
||||
value={payload.critical_occurrences}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
set('critical_occurrences', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Referência das evidências
|
||||
<textarea
|
||||
className={controlClass + ' h-auto min-h-20 py-2'}
|
||||
value={payload.critical_evidence}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
set('critical_evidence', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</details>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import { Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Person = { id: number; name: string };
|
||||
type Plan = {
|
||||
title: string;
|
||||
description: string;
|
||||
responsible_id: string;
|
||||
due_on: string;
|
||||
};
|
||||
type Measure = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
responsible_id: number;
|
||||
due_on: string;
|
||||
status: string;
|
||||
version: number;
|
||||
result: string | null;
|
||||
evidence: string | null;
|
||||
reviewed_by: number | null;
|
||||
review_reason: string | null;
|
||||
reviewed_at: string | null;
|
||||
canPlan: boolean;
|
||||
canExecute: boolean;
|
||||
canReview: boolean;
|
||||
};
|
||||
type Event = {
|
||||
id: number;
|
||||
action: string;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
after: Measure & { change_reason?: string };
|
||||
};
|
||||
export type ImprovementData = {
|
||||
ready: boolean;
|
||||
canCreate: boolean;
|
||||
today: string;
|
||||
records: Measure[];
|
||||
assignees: Person[];
|
||||
history: {
|
||||
data: Event[];
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
} | null;
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
open: 'Em execução',
|
||||
review: 'A aguardar conferência',
|
||||
returned: 'Devolvida para correcção',
|
||||
closed: 'Encerrada',
|
||||
};
|
||||
const events: Record<string, string> = {
|
||||
create: 'Criação',
|
||||
plan: 'Alteração do plano',
|
||||
progress: 'Execução registada',
|
||||
submit: 'Pedido de encerramento',
|
||||
return: 'Devolução',
|
||||
close: 'Encerramento independente',
|
||||
};
|
||||
const freshPlan: Plan = {
|
||||
title: '',
|
||||
description: '',
|
||||
responsible_id: '',
|
||||
due_on: '',
|
||||
};
|
||||
function Errors({ errors }: { errors: Record<string, string> }) {
|
||||
return Object.keys(errors).length ? (
|
||||
<div role="alert" className="rounded border border-red-300 p-3 text-sm">
|
||||
{Object.entries(errors).map(([key, text]) => (
|
||||
<p key={key}>{text}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
function PlanFields({
|
||||
data,
|
||||
change,
|
||||
people,
|
||||
}: {
|
||||
data: Plan;
|
||||
change: (key: keyof Plan, value: string) => void;
|
||||
people: Person[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Medida
|
||||
<input
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={200}
|
||||
className={controlClass}
|
||||
value={data.title}
|
||||
onChange={(e) => change('title', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Objectivo e resultado esperado
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={10000}
|
||||
className={controlClass}
|
||||
value={data.description}
|
||||
onChange={(e) => change('description', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Responsável interno
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={data.responsible_id}
|
||||
onChange={(e) => change('responsible_id', e.target.value)}
|
||||
>
|
||||
<option value="">Escolher responsável</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Prazo
|
||||
<input
|
||||
required
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={data.due_on}
|
||||
onChange={(e) => change('due_on', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function MeasureCard({
|
||||
measure: m,
|
||||
base,
|
||||
people,
|
||||
names,
|
||||
today,
|
||||
}: {
|
||||
measure: Measure;
|
||||
base: string;
|
||||
people: Person[];
|
||||
names: Record<number, string>;
|
||||
today: string;
|
||||
}) {
|
||||
const plan = useForm({
|
||||
version: m.version,
|
||||
title: m.title,
|
||||
description: m.description,
|
||||
responsible_id: String(m.responsible_id),
|
||||
due_on: m.due_on,
|
||||
reason: '',
|
||||
});
|
||||
const progress = useForm({
|
||||
version: m.version,
|
||||
result: m.result ?? '',
|
||||
evidence: m.evidence ?? '',
|
||||
});
|
||||
const decision = useForm({ version: m.version, reason: '' });
|
||||
const url = `${base}/${m.id}`;
|
||||
const overdue = m.status !== 'closed' && m.due_on < today;
|
||||
return (
|
||||
<article id={`medida-${m.id}`} className="space-y-4 rounded border p-4">
|
||||
<div className="flex flex-wrap justify-between gap-2">
|
||||
<h3 className="font-semibold">
|
||||
MM-{m.id} · {m.title}
|
||||
</h3>
|
||||
<span
|
||||
className={
|
||||
overdue
|
||||
? 'font-medium text-amber-800 dark:text-amber-300'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{states[m.status]}
|
||||
{overdue ? ' · Prazo ultrapassado' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{m.description}</p>
|
||||
<p className="text-sm">
|
||||
Responsável: {names[m.responsible_id] ?? `#${m.responsible_id}`}{' '}
|
||||
· Prazo: {m.due_on} · Versão {m.version}
|
||||
</p>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<dt className="font-medium">Resultado registado</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{m.result || 'Ainda não registado.'}
|
||||
</dd>
|
||||
<dt className="font-medium">
|
||||
Evidência / referência documental
|
||||
</dt>
|
||||
<dd className="whitespace-pre-wrap">
|
||||
{m.evidence || 'Ainda não indicada.'}
|
||||
</dd>
|
||||
</dl>
|
||||
{m.review_reason && (
|
||||
<p className="rounded bg-muted p-3 text-sm whitespace-pre-wrap">
|
||||
Última conferência por{' '}
|
||||
{names[m.reviewed_by ?? 0] ?? `#${m.reviewed_by}`}
|
||||
{m.reviewed_at
|
||||
? ` em ${new Date(m.reviewed_at).toLocaleString('pt-MZ')}`
|
||||
: ''}
|
||||
: {m.review_reason}
|
||||
</p>
|
||||
)}
|
||||
{m.canPlan && (
|
||||
<details className="print:hidden">
|
||||
<summary className="cursor-pointer text-sm underline">
|
||||
Alterar plano, responsável ou prazo
|
||||
</summary>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
plan.post(url + '/plan', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Errors errors={plan.errors} />
|
||||
<PlanFields
|
||||
data={plan.data}
|
||||
change={(key, value) => plan.setData(key, value)}
|
||||
people={people}
|
||||
/>
|
||||
<label className="block space-y-1">
|
||||
Justificação da alteração
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={plan.data.reason}
|
||||
onChange={(e) =>
|
||||
plan.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={plan.processing}>
|
||||
Guardar alteração justificada
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
{m.canExecute && (
|
||||
<form
|
||||
className="space-y-4 print:hidden"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
progress.post(url + '/progress', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Errors errors={progress.errors} />
|
||||
<label className="block space-y-1">
|
||||
Resultado da execução
|
||||
<textarea
|
||||
maxLength={10000}
|
||||
className={controlClass}
|
||||
value={progress.data.result}
|
||||
onChange={(e) =>
|
||||
progress.setData('result', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
Referência da evidência
|
||||
<textarea
|
||||
maxLength={10000}
|
||||
className={controlClass}
|
||||
value={progress.data.evidence}
|
||||
onChange={(e) =>
|
||||
progress.setData('evidence', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={progress.processing}
|
||||
>
|
||||
Guardar progresso
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
progress.processing ||
|
||||
!progress.data.result.trim() ||
|
||||
!progress.data.evidence.trim()
|
||||
}
|
||||
onClick={() =>
|
||||
progress.post(url + '/submit', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Pedir encerramento
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{m.canReview && (
|
||||
<div className="space-y-3 print:hidden">
|
||||
<Errors errors={decision.errors} />
|
||||
<label className="block space-y-1">
|
||||
Fundamentação da conferência
|
||||
<textarea
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={decision.data.reason}
|
||||
onChange={(e) =>
|
||||
decision.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
disabled={
|
||||
decision.processing ||
|
||||
decision.data.reason.trim().length < 10
|
||||
}
|
||||
onClick={() =>
|
||||
decision.post(url + '/close', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Confirmar encerramento
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
decision.processing ||
|
||||
decision.data.reason.trim().length < 10
|
||||
}
|
||||
onClick={() =>
|
||||
decision.post(url + '/return', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Devolver para correcção
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export default function SupplierImprovements({
|
||||
data,
|
||||
evaluationId,
|
||||
names,
|
||||
reportOnly,
|
||||
generatedAt,
|
||||
}: {
|
||||
data: ImprovementData;
|
||||
evaluationId: number;
|
||||
names: Record<number, string>;
|
||||
reportOnly: boolean;
|
||||
generatedAt: string;
|
||||
}) {
|
||||
const create = useForm(freshPlan);
|
||||
const operation = useRef<string | null>(null);
|
||||
const base = `/fornecedores/avaliacoes/${evaluationId}/medidas`;
|
||||
const overdue = data.records.filter(
|
||||
(m) => m.status !== 'closed' && m.due_on < data.today,
|
||||
).length;
|
||||
return (
|
||||
<Panel title="Medidas de melhoria e acompanhamento">
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Acompanhamento associado a esta revisão, consultado em{' '}
|
||||
{new Date(generatedAt).toLocaleString('pt-MZ')}. Não altera
|
||||
o conteúdo da avaliação aprovada nem a elegibilidade do
|
||||
fornecedor. O prazo ultrapassado é um aviso; não encerra a
|
||||
medida automaticamente.
|
||||
</p>
|
||||
{!data.ready ? (
|
||||
<p>
|
||||
A instalação das medidas de melhoria ainda não está
|
||||
concluída.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
{data.records.length} medida(s) ·{' '}
|
||||
{
|
||||
data.records.filter(
|
||||
(m) => m.status === 'closed',
|
||||
).length
|
||||
}{' '}
|
||||
encerrada(s) · {overdue} com prazo ultrapassado
|
||||
</p>
|
||||
{data.records.length === 0 && (
|
||||
<p className="text-sm">
|
||||
Sem medidas registadas nesta revisão. As medidas
|
||||
de revisões anteriores permanecem nos
|
||||
respectivos relatórios.
|
||||
</p>
|
||||
)}
|
||||
{data.records.map((m) => (
|
||||
<MeasureCard
|
||||
key={`${m.id}-${m.version}`}
|
||||
measure={m}
|
||||
base={base}
|
||||
people={data.assignees}
|
||||
names={names}
|
||||
today={data.today}
|
||||
/>
|
||||
))}
|
||||
{data.canCreate && (
|
||||
<details className="rounded border p-4 print:hidden">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Adicionar medida de melhoria
|
||||
</summary>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
operation.current ??=
|
||||
crypto.randomUUID();
|
||||
create.transform((values) => ({
|
||||
...values,
|
||||
operation_key: operation.current,
|
||||
}));
|
||||
create.post(base, {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Errors errors={create.errors} />
|
||||
<PlanFields
|
||||
data={create.data}
|
||||
change={(key, value) =>
|
||||
create.setData(key, value)
|
||||
}
|
||||
people={data.assignees}
|
||||
/>
|
||||
<Button disabled={create.processing}>
|
||||
Registar medida
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
{!reportOnly &&
|
||||
data.history &&
|
||||
data.history.total > 0 && (
|
||||
<details className="print:hidden">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Histórico das medidas (
|
||||
{data.history.total})
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{data.history.data.map((event) => (
|
||||
<details
|
||||
key={event.id}
|
||||
className="rounded border p-3"
|
||||
>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
MM-{event.after.id} ·{' '}
|
||||
{events[
|
||||
event.action.split(
|
||||
'.',
|
||||
)[1]
|
||||
] ?? event.action}{' '}
|
||||
·{' '}
|
||||
{names[event.user_id] ??
|
||||
`#${event.user_id}`}{' '}
|
||||
·{' '}
|
||||
{new Date(
|
||||
event.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2 text-sm whitespace-pre-wrap">
|
||||
<p>
|
||||
{event.after.title} ·{' '}
|
||||
{
|
||||
states[
|
||||
event.after
|
||||
.status
|
||||
]
|
||||
}{' '}
|
||||
· Versão{' '}
|
||||
{event.after.version}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
event.after
|
||||
.description
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
Responsável:{' '}
|
||||
{names[
|
||||
event.after
|
||||
.responsible_id
|
||||
] ??
|
||||
`#${event.after.responsible_id}`}{' '}
|
||||
· Prazo:{' '}
|
||||
{event.after.due_on}
|
||||
</p>
|
||||
{event.after
|
||||
.change_reason && (
|
||||
<p>
|
||||
Alteração:{' '}
|
||||
{
|
||||
event.after
|
||||
.change_reason
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
{event.after.result ||
|
||||
'Sem resultado registado.'}
|
||||
</p>
|
||||
<p>
|
||||
{event.after.evidence ||
|
||||
'Sem evidência registada.'}
|
||||
</p>
|
||||
{event.after
|
||||
.review_reason && (
|
||||
<p>
|
||||
Conferência:{' '}
|
||||
{
|
||||
event.after
|
||||
.review_reason
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Paginação do histórico das medidas"
|
||||
className="mt-3 flex gap-4 text-sm"
|
||||
>
|
||||
{data.history.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
data.history.prev_page_url
|
||||
}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{data.history.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
data.history.next_page_url
|
||||
}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import { Link, useForm } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import { Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Payload = {
|
||||
title: string;
|
||||
received_on: string | null;
|
||||
source_reference: string;
|
||||
statement: string;
|
||||
};
|
||||
type Decision = {
|
||||
outcome: string;
|
||||
reason: string;
|
||||
follow_up: string;
|
||||
response_reference: string;
|
||||
};
|
||||
type Record = {
|
||||
id: number;
|
||||
version: number;
|
||||
status: string;
|
||||
payload: Payload;
|
||||
decision: Decision | null;
|
||||
created_by: number;
|
||||
reviewed_by: number | null;
|
||||
reviewed_at: string | null;
|
||||
review_reason: string | null;
|
||||
canEdit: boolean;
|
||||
canReview: boolean;
|
||||
};
|
||||
type History = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
action: string;
|
||||
created_at: string;
|
||||
after: Record;
|
||||
};
|
||||
export type ResponseData = {
|
||||
ready: boolean;
|
||||
canCreate: boolean;
|
||||
records: Record[];
|
||||
history: {
|
||||
data: History[];
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
} | null;
|
||||
};
|
||||
const states: { [key: string]: string } = {
|
||||
draft: 'Em preparação',
|
||||
review: 'Em análise',
|
||||
returned: 'Devolvido para completar',
|
||||
decided: 'Conclusão registada',
|
||||
};
|
||||
const outcomes: { [key: string]: string } = {
|
||||
accepted: 'Argumentos acolhidos',
|
||||
partially_accepted: 'Argumentos parcialmente acolhidos',
|
||||
not_accepted: 'Argumentos não acolhidos',
|
||||
};
|
||||
const actions: { [key: string]: string } = {
|
||||
create: 'Registo',
|
||||
save: 'Alteração',
|
||||
submit: 'Submissão para análise',
|
||||
return: 'Devolução',
|
||||
decide: 'Conclusão independente',
|
||||
};
|
||||
const empty: Payload = {
|
||||
title: '',
|
||||
received_on: '',
|
||||
source_reference: '',
|
||||
statement: '',
|
||||
};
|
||||
function Errors({ errors }: { errors: { [key: string]: string } }) {
|
||||
return Object.keys(errors).length ? (
|
||||
<div role="alert" className="rounded border border-red-300 p-3 text-sm">
|
||||
{Object.entries(errors).map(([key, message]) => (
|
||||
<p key={key}>{message}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
function Fields({
|
||||
payload,
|
||||
change,
|
||||
}: {
|
||||
payload: Payload;
|
||||
change: (key: keyof Payload, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
Assunto
|
||||
<input
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={200}
|
||||
className={controlClass}
|
||||
value={payload.title}
|
||||
onChange={(e) => change('title', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Recebido em
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={payload.received_on ?? ''}
|
||||
onChange={(e) => change('received_on', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Documento ou origem recebida
|
||||
<textarea
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={payload.source_reference}
|
||||
onChange={(e) => change('source_reference', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Argumentos apresentados pelo fornecedor
|
||||
<textarea
|
||||
rows={5}
|
||||
maxLength={20000}
|
||||
className={controlClass}
|
||||
value={payload.statement}
|
||||
onChange={(e) => change('statement', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ResponseCard({
|
||||
record: r,
|
||||
base,
|
||||
names,
|
||||
}: {
|
||||
record: Record;
|
||||
base: string;
|
||||
names: { [key: number]: string };
|
||||
}) {
|
||||
const edit = useForm({ version: r.version, payload: r.payload });
|
||||
const submit = useForm({ version: r.version });
|
||||
const review = useForm({
|
||||
version: r.version,
|
||||
outcome: '',
|
||||
reason: '',
|
||||
follow_up: '',
|
||||
response_reference: '',
|
||||
});
|
||||
const url = `${base}/${r.id}`;
|
||||
const dirty =
|
||||
JSON.stringify(edit.data.payload) !== JSON.stringify(r.payload);
|
||||
return (
|
||||
<article
|
||||
id={`contraditorio-${r.id}`}
|
||||
className="space-y-4 rounded border p-4"
|
||||
>
|
||||
<h3 className="font-semibold">
|
||||
CTR-{r.id} · {r.payload.title}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{states[r.status]} · Versão {r.version} · Registado por{' '}
|
||||
{names[r.created_by] ?? `#${r.created_by}`}
|
||||
</p>
|
||||
{r.canEdit ? (
|
||||
<form
|
||||
className="space-y-4 print:hidden"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
edit.post(url + '/save', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Errors errors={edit.errors} />
|
||||
<Errors errors={submit.errors} />
|
||||
<Fields
|
||||
payload={edit.data.payload}
|
||||
change={(key, value) =>
|
||||
edit.setData('payload', {
|
||||
...edit.data.payload,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="outline" disabled={edit.processing}>
|
||||
Guardar registo
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
edit.processing || submit.processing || dirty
|
||||
}
|
||||
onClick={() =>
|
||||
submit.post(url + '/submit', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Submeter para análise
|
||||
</Button>
|
||||
</div>
|
||||
{dirty && (
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
Guarde as alterações antes de submeter.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm whitespace-pre-wrap">
|
||||
<p>
|
||||
Recebido em:{' '}
|
||||
{r.payload.received_on || 'Ainda não indicado'}
|
||||
</p>
|
||||
<p>
|
||||
Documento/origem:{' '}
|
||||
{r.payload.source_reference || 'Ainda não indicado'}
|
||||
</p>
|
||||
<p>
|
||||
{r.payload.statement ||
|
||||
'Argumentos ainda não transcritos.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{r.review_reason && (
|
||||
<p className="rounded bg-muted p-3 text-sm whitespace-pre-wrap">
|
||||
Última análise por{' '}
|
||||
{names[r.reviewed_by ?? 0] ?? `#${r.reviewed_by}`}
|
||||
{r.reviewed_at
|
||||
? ` em ${new Date(r.reviewed_at).toLocaleString('pt-MZ')}`
|
||||
: ''}
|
||||
: {r.review_reason}
|
||||
</p>
|
||||
)}
|
||||
{r.decision && (
|
||||
<div className="space-y-2 text-sm whitespace-pre-wrap">
|
||||
<p className="font-semibold">
|
||||
{outcomes[r.decision.outcome]}
|
||||
</p>
|
||||
{r.decision.follow_up && (
|
||||
<p>Seguimento: {r.decision.follow_up}</p>
|
||||
)}
|
||||
{r.decision.response_reference && (
|
||||
<p>
|
||||
Referência da resposta:{' '}
|
||||
{r.decision.response_reference}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{r.canReview && (
|
||||
<div className="space-y-4 print:hidden">
|
||||
<Errors errors={review.errors} />
|
||||
<label className="block space-y-1">
|
||||
Conclusão
|
||||
<select
|
||||
className={controlClass}
|
||||
value={review.data.outcome}
|
||||
onChange={(e) =>
|
||||
review.setData('outcome', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Escolher conclusão</option>
|
||||
{Object.entries(outcomes).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
Análise fundamentada
|
||||
<textarea
|
||||
minLength={10}
|
||||
maxLength={10000}
|
||||
rows={4}
|
||||
className={controlClass}
|
||||
value={review.data.reason}
|
||||
onChange={(e) =>
|
||||
review.setData('reason', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
Seguimento a dar
|
||||
<textarea
|
||||
maxLength={10000}
|
||||
className={controlClass}
|
||||
value={review.data.follow_up}
|
||||
onChange={(e) =>
|
||||
review.setData('follow_up', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
Referência da resposta preparada (opcional)
|
||||
<input
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={review.data.response_reference}
|
||||
onChange={(e) =>
|
||||
review.setData(
|
||||
'response_reference',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
disabled={
|
||||
review.processing ||
|
||||
!review.data.outcome ||
|
||||
review.data.reason.trim().length < 10
|
||||
}
|
||||
onClick={() => {
|
||||
review.transform((values) => values);
|
||||
review.post(url + '/decide', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Registar conclusão da análise
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
review.processing ||
|
||||
review.data.reason.trim().length < 10
|
||||
}
|
||||
onClick={() => {
|
||||
review.transform((values) => ({
|
||||
version: values.version,
|
||||
reason: values.reason,
|
||||
}));
|
||||
review.post(url + '/return', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Devolver para completar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
export default function SupplierResponses({
|
||||
data,
|
||||
evaluationId,
|
||||
names,
|
||||
reportOnly,
|
||||
generatedAt,
|
||||
}: {
|
||||
data: ResponseData;
|
||||
evaluationId: number;
|
||||
names: { [key: number]: string };
|
||||
reportOnly: boolean;
|
||||
generatedAt: string;
|
||||
}) {
|
||||
const create = useForm({ payload: empty });
|
||||
const operation = useRef<string | null>(null);
|
||||
const base = `/fornecedores/avaliacoes/${evaluationId}/contraditorios`;
|
||||
return (
|
||||
<Panel title="Contraditório do fornecedor">
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Documentos e argumentos recebidos, registados pela equipa.
|
||||
Acompanhamento consultado em{' '}
|
||||
{new Date(generatedAt).toLocaleString('pt-MZ')}. A conclusão
|
||||
fica registada para comunicação ao fornecedor; a avaliação
|
||||
aprovada conserva-se e qualquer rectificação exige uma nova
|
||||
revisão.
|
||||
</p>
|
||||
{!data.ready ? (
|
||||
<p>
|
||||
A instalação do contraditório ainda não está concluída.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{!data.records.length && (
|
||||
<p className="text-sm">
|
||||
Sem contraditórios registados nesta revisão.
|
||||
</p>
|
||||
)}
|
||||
{data.records.map((r) => (
|
||||
<ResponseCard
|
||||
key={`${r.id}-${r.version}`}
|
||||
record={r}
|
||||
base={base}
|
||||
names={names}
|
||||
/>
|
||||
))}
|
||||
{data.canCreate && (
|
||||
<details className="rounded border p-4 print:hidden">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Registar documento recebido
|
||||
</summary>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
operation.current ??=
|
||||
crypto.randomUUID();
|
||||
create.transform((values) => ({
|
||||
...values,
|
||||
operation_key: operation.current,
|
||||
}));
|
||||
create.post(base, {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Errors errors={create.errors} />
|
||||
<Fields
|
||||
payload={create.data.payload}
|
||||
change={(key, value) =>
|
||||
create.setData('payload', {
|
||||
...create.data.payload,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button disabled={create.processing}>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
{!reportOnly &&
|
||||
data.history &&
|
||||
data.history.total > 0 && (
|
||||
<details className="print:hidden">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Histórico do contraditório (
|
||||
{data.history.total})
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{data.history.data.map((event) => (
|
||||
<details
|
||||
className="rounded border p-3"
|
||||
key={event.id}
|
||||
>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
CTR-{event.after.id} ·{' '}
|
||||
{actions[
|
||||
event.action.split(
|
||||
'.',
|
||||
)[1]
|
||||
] ?? event.action}{' '}
|
||||
·{' '}
|
||||
{names[event.user_id] ??
|
||||
`#${event.user_id}`}{' '}
|
||||
·{' '}
|
||||
{new Date(
|
||||
event.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2 text-sm whitespace-pre-wrap">
|
||||
<p>
|
||||
{
|
||||
event.after.payload
|
||||
.title
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
states[
|
||||
event.after
|
||||
.status
|
||||
]
|
||||
}{' '}
|
||||
· Versão{' '}
|
||||
{event.after.version}
|
||||
</p>
|
||||
<p>
|
||||
Recebido:{' '}
|
||||
{event.after.payload
|
||||
.received_on ||
|
||||
'Não indicado'}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
event.after.payload
|
||||
.source_reference
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
event.after.payload
|
||||
.statement
|
||||
}
|
||||
</p>
|
||||
{event.after
|
||||
.review_reason && (
|
||||
<p>
|
||||
Análise:{' '}
|
||||
{
|
||||
event.after
|
||||
.review_reason
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
{event.after.decision && (
|
||||
<>
|
||||
<p>
|
||||
{
|
||||
outcomes[
|
||||
event
|
||||
.after
|
||||
.decision
|
||||
.outcome
|
||||
]
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
event.after
|
||||
.decision
|
||||
.follow_up
|
||||
}
|
||||
</p>
|
||||
<p>
|
||||
{
|
||||
event.after
|
||||
.decision
|
||||
.response_reference
|
||||
}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Paginação do histórico do contraditório"
|
||||
className="mt-3 flex gap-4 text-sm"
|
||||
>
|
||||
{data.history.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
data.history.prev_page_url
|
||||
}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{data.history.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={
|
||||
data.history.next_page_url
|
||||
}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Users,
|
||||
Wallet,
|
||||
Warehouse,
|
||||
FileBarChart,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
@@ -27,7 +28,7 @@ const finance = ['admin', 'finance'];
|
||||
const inventory = [...readers, 'warehouse'];
|
||||
|
||||
export function procurementNavigation(
|
||||
role: string,
|
||||
role: string | string[],
|
||||
payables: boolean,
|
||||
): ProcurementGroup[] {
|
||||
const groups: ProcurementGroup[] = [
|
||||
@@ -205,6 +206,12 @@ export function procurementNavigation(
|
||||
path: '/empresa',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'organization_parameters',
|
||||
label: 'Parametrização da aplicação',
|
||||
path: '/administracao/parametrizacao',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'admission_policy',
|
||||
label: 'Política de admissão',
|
||||
@@ -224,12 +231,40 @@ export function procurementNavigation(
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reports',
|
||||
label: 'Relatórios',
|
||||
icon: FileBarChart,
|
||||
entries: [
|
||||
{
|
||||
id: 'procurement_reports',
|
||||
label: 'Relatórios de compras',
|
||||
path: '/relatorios',
|
||||
roles: readers,
|
||||
},
|
||||
{
|
||||
id: 'supplier_improvement_reports',
|
||||
label: 'Medidas de melhoria',
|
||||
path: '/relatorios/medidas-fornecedores',
|
||||
roles: readers,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: group.entries.filter(
|
||||
(entry) => !entry.roles || entry.roles.includes(role),
|
||||
(entry) =>
|
||||
(entry.id !== 'new_request' ||
|
||||
!Array.isArray(role) ||
|
||||
role.includes('requester')) &&
|
||||
(!entry.roles ||
|
||||
entry.roles.some((permission) =>
|
||||
(Array.isArray(role) ? role : [role]).includes(
|
||||
permission,
|
||||
),
|
||||
)),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.entries.length);
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
FormErrors,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Profile = { id: number; name: string; permissions: string[] };
|
||||
type Member = {
|
||||
user_id: number;
|
||||
profile_id: number;
|
||||
maintenance: boolean;
|
||||
expires_at: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
type Person = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
department_id: number;
|
||||
};
|
||||
const base = '/administracao/parametrizacao';
|
||||
function Choices({
|
||||
profiles,
|
||||
selected,
|
||||
change,
|
||||
}: {
|
||||
profiles: Profile[];
|
||||
selected: number[];
|
||||
change: (ids: number[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{profiles.map((p) => (
|
||||
<label className="flex items-center gap-2 text-sm" key={p.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(p.id)}
|
||||
onChange={(e) =>
|
||||
change(
|
||||
e.target.checked
|
||||
? [...selected, p.id]
|
||||
: selected.filter((id) => id !== p.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function Assignment({
|
||||
person,
|
||||
profiles,
|
||||
members,
|
||||
version,
|
||||
}: {
|
||||
person: Person;
|
||||
profiles: Profile[];
|
||||
members: Member[];
|
||||
version: number;
|
||||
}) {
|
||||
const assigned = members.filter((m) => m.user_id === person.id);
|
||||
const form = useForm({
|
||||
version,
|
||||
user_id: person.id,
|
||||
profile_ids: assigned.map((m) => m.profile_id),
|
||||
maintenance: assigned.some((m) => m.maintenance),
|
||||
expires_at: '',
|
||||
reason: '',
|
||||
});
|
||||
return (
|
||||
<details className="rounded border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
{person.name} · {person.email} · {assigned.length} perfil(is)
|
||||
</summary>
|
||||
<form
|
||||
className="mt-4 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.transform((values) => ({
|
||||
...values,
|
||||
expires_at: values.expires_at
|
||||
? new Date(values.expires_at).toISOString()
|
||||
: null,
|
||||
}));
|
||||
form.post(base + '/assign', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<Choices
|
||||
profiles={profiles}
|
||||
selected={form.data.profile_ids}
|
||||
change={(ids) => form.setData('profile_ids', ids)}
|
||||
/>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.maintenance}
|
||||
onChange={(e) =>
|
||||
form.setData('maintenance', e.target.checked)
|
||||
}
|
||||
/>
|
||||
Manutenção TEMPO — diagnóstico e consulta temporários
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
Expiração (obrigatória para manutenção)
|
||||
<input
|
||||
className={controlClass}
|
||||
type="datetime-local"
|
||||
required={form.data.maintenance}
|
||||
value={form.data.expires_at}
|
||||
onChange={(e) =>
|
||||
form.setData('expires_at', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{assigned.some((m) => m.expires_at) && (
|
||||
<p className="text-sm">
|
||||
Expiração anterior:{' '}
|
||||
{assigned.find((m) => m.expires_at)?.expires_at}.
|
||||
Indique a expiração para manter um acesso temporário.
|
||||
</p>
|
||||
)}
|
||||
<label className="block space-y-1">
|
||||
Fundamento da atribuição ou revogação
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
className={controlClass}
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sem perfis seleccionados, o acesso funcional será revogado
|
||||
quando a parametrização estiver activa. Os dois
|
||||
administradores permanentes devem ser mantidos.
|
||||
</p>
|
||||
<Button disabled={form.processing}>Guardar atribuição</Button>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
export default function Parameters({
|
||||
ready,
|
||||
active,
|
||||
version,
|
||||
permissions,
|
||||
profiles,
|
||||
memberships,
|
||||
users,
|
||||
departments,
|
||||
costCenters,
|
||||
notice,
|
||||
companyRules,
|
||||
}: {
|
||||
ready: boolean;
|
||||
active: boolean;
|
||||
version: number;
|
||||
permissions: { [key: string]: string };
|
||||
profiles: Profile[];
|
||||
memberships: Member[];
|
||||
users: Person[];
|
||||
departments: { id: number; name: string }[];
|
||||
costCenters: { id: number; code: string; name: string }[];
|
||||
notice?: string;
|
||||
companyRules: {
|
||||
first_limit: string;
|
||||
second_limit: string;
|
||||
includes_tax: boolean;
|
||||
provisional: boolean;
|
||||
};
|
||||
}) {
|
||||
const profile = useForm({
|
||||
version,
|
||||
id: '',
|
||||
name: '',
|
||||
permissions: [] as string[],
|
||||
});
|
||||
const user = useForm({
|
||||
version,
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
department_id: '',
|
||||
profile_ids: [] as number[],
|
||||
reason: '',
|
||||
});
|
||||
const activate = useForm({ version, confirm: false, reason: '' });
|
||||
const rules = useForm({
|
||||
version,
|
||||
first_limit: companyRules.first_limit,
|
||||
second_limit: companyRules.second_limit,
|
||||
includes_tax: companyRules.includes_tax,
|
||||
reason: '',
|
||||
});
|
||||
const department = useForm({ name: '' });
|
||||
const center = useForm({ department_id: '', code: '', name: '' });
|
||||
return (
|
||||
<>
|
||||
<Head title="Parametrização da aplicação" />
|
||||
<div className="mx-auto max-w-6xl space-y-6 px-5 py-8 lg:px-8">
|
||||
<PageTitle
|
||||
eyebrow="Administração"
|
||||
title="Parametrização da aplicação"
|
||||
description="Defina os perfis da empresa, atribua responsabilidades e mantenha dois administradores permanentes."
|
||||
/>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-teal-300 p-3"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!ready ? (
|
||||
<Panel title="Instalação pendente">
|
||||
<p>
|
||||
A parametrização ainda não foi instalada neste
|
||||
ambiente.
|
||||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<Panel
|
||||
title={
|
||||
active
|
||||
? 'Parametrização activa'
|
||||
: 'Preparar a configuração da empresa'
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
{active
|
||||
? 'Os acessos são determinados pelos perfis e atribuições abaixo.'
|
||||
: 'Crie os perfis e associe os utilizadores antes de activar. A activação substitui os perfis anteriores; utilizadores sem atribuições válidas deixam de ter acesso funcional.'}{' '}
|
||||
Versão {version}.
|
||||
</p>
|
||||
{!active && (
|
||||
<form
|
||||
className="mt-4 space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
activate.post(base + '/activate', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={activate.errors} />
|
||||
<label className="block">
|
||||
Motivo da activação
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={activate.data.reason}
|
||||
onChange={(e) =>
|
||||
activate.setData(
|
||||
'reason',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
required
|
||||
type="checkbox"
|
||||
checked={activate.data.confirm}
|
||||
onChange={(e) =>
|
||||
activate.setData(
|
||||
'confirm',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Conferi os dois administradores e o
|
||||
efeito das atribuições sobre todos os
|
||||
utilizadores.
|
||||
</label>
|
||||
<Button
|
||||
disabled={
|
||||
activate.processing ||
|
||||
!activate.data.confirm
|
||||
}
|
||||
>
|
||||
Activar configuração da empresa
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="Regras de aprovação da empresa">
|
||||
<p className="mb-4 text-sm">
|
||||
{companyRules.provisional
|
||||
? 'Os valores actuais são provisórios; confirme e guarde as regras da empresa.'
|
||||
: 'Regras definidas pela empresa.'}{' '}
|
||||
Alterações aplicam-se a novas submissões. Os
|
||||
processos já submetidos conservam as regras
|
||||
anteriores.
|
||||
</p>
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
rules.post(base + '/rules', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="sm:col-span-2">
|
||||
<FormErrors errors={rules.errors} />
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
Primeiro limite (MZN)
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+(\.[0-9]{1,2})?"
|
||||
className={controlClass}
|
||||
value={rules.data.first_limit}
|
||||
onChange={(e) =>
|
||||
rules.setData(
|
||||
'first_limit',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Segundo limite (MZN)
|
||||
<input
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+(\.[0-9]{1,2})?"
|
||||
className={controlClass}
|
||||
value={rules.data.second_limit}
|
||||
onChange={(e) =>
|
||||
rules.setData(
|
||||
'second_limit',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules.data.includes_tax}
|
||||
onChange={(e) =>
|
||||
rules.setData(
|
||||
'includes_tax',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Aplicar os limites ao valor com imposto
|
||||
</label>
|
||||
<p className="text-sm sm:col-span-2">
|
||||
Supervisão do departamento na primeira
|
||||
etapa; acima do primeiro limite
|
||||
acrescenta-se Compras; acima do segundo
|
||||
acrescenta-se Finanças. Equipamentos mantêm
|
||||
a conferência financeira e técnica. Atribua
|
||||
estas funções às pessoas através dos perfis.
|
||||
</p>
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Fundamentação
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={rules.data.reason}
|
||||
onChange={(e) =>
|
||||
rules.setData(
|
||||
'reason',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={rules.processing}>
|
||||
Guardar regras da empresa
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title="Perfis e permissões da empresa">
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
{profiles.map((p) => (
|
||||
<Button
|
||||
key={p.id}
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
profile.setData({
|
||||
version,
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
permissions: p.permissions,
|
||||
})
|
||||
}
|
||||
>
|
||||
{p.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
profile.transform((values) => ({
|
||||
...values,
|
||||
id: values.id || null,
|
||||
}));
|
||||
profile.post(base + '/profile', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={profile.errors} />
|
||||
<label className="block space-y-1">
|
||||
Nome do perfil
|
||||
<input
|
||||
required
|
||||
maxLength={120}
|
||||
className={controlClass}
|
||||
value={profile.data.name}
|
||||
onChange={(e) =>
|
||||
profile.setData(
|
||||
'name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<fieldset className="grid gap-3 sm:grid-cols-2">
|
||||
<legend className="mb-2 font-medium">
|
||||
Permissões funcionais
|
||||
</legend>
|
||||
{Object.entries(permissions).map(
|
||||
([key, label]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={profile.data.permissions.includes(
|
||||
key,
|
||||
)}
|
||||
onChange={(e) =>
|
||||
profile.setData(
|
||||
'permissions',
|
||||
e.target.checked
|
||||
? [
|
||||
...profile
|
||||
.data
|
||||
.permissions,
|
||||
key,
|
||||
]
|
||||
: profile.data.permissions.filter(
|
||||
(p) =>
|
||||
p !==
|
||||
key,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="flex gap-3">
|
||||
<Button disabled={profile.processing}>
|
||||
{profile.data.id
|
||||
? 'Actualizar perfil'
|
||||
: 'Criar perfil'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
profile.setData({
|
||||
version,
|
||||
id: '',
|
||||
name: '',
|
||||
permissions: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Novo perfil
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
<Panel title="Utilizadores e atribuições">
|
||||
<div className="space-y-3">
|
||||
{users.map((person) => (
|
||||
<Assignment
|
||||
key={`${person.id}-${version}`}
|
||||
person={person}
|
||||
profiles={profiles}
|
||||
members={memberships}
|
||||
version={version}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<details className="mt-5 rounded border p-4">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Criar utilizador
|
||||
</summary>
|
||||
<form
|
||||
className="mt-4 grid gap-4 sm:grid-cols-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
user.post(base + '/user', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
onSuccess: () =>
|
||||
user.reset(
|
||||
'password',
|
||||
'password_confirmation',
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="sm:col-span-2">
|
||||
<FormErrors errors={user.errors} />
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'password_confirmation',
|
||||
] as const
|
||||
).map((key) => (
|
||||
<label className="space-y-1" key={key}>
|
||||
{
|
||||
{
|
||||
name: 'Nome',
|
||||
email: 'Email',
|
||||
password:
|
||||
'Palavra-passe inicial',
|
||||
password_confirmation:
|
||||
'Confirmar palavra-passe',
|
||||
}[key]
|
||||
}
|
||||
<input
|
||||
required
|
||||
type={
|
||||
key.includes('password')
|
||||
? 'password'
|
||||
: key === 'email'
|
||||
? 'email'
|
||||
: 'text'
|
||||
}
|
||||
minLength={
|
||||
key.includes('password')
|
||||
? 12
|
||||
: undefined
|
||||
}
|
||||
autoComplete={
|
||||
key.includes('password')
|
||||
? 'new-password'
|
||||
: undefined
|
||||
}
|
||||
className={controlClass}
|
||||
value={user.data[key]}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
key,
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="space-y-1">
|
||||
Departamento
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={user.data.department_id}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
'department_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Escolher</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<p>Perfis atribuídos</p>
|
||||
<Choices
|
||||
profiles={profiles}
|
||||
selected={user.data.profile_ids}
|
||||
change={(ids) =>
|
||||
user.setData('profile_ids', ids)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className="space-y-1 sm:col-span-2">
|
||||
Fundamento
|
||||
<textarea
|
||||
required
|
||||
minLength={10}
|
||||
className={controlClass}
|
||||
value={user.data.reason}
|
||||
onChange={(e) =>
|
||||
user.setData(
|
||||
'reason',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={user.processing}>
|
||||
Criar utilizador
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
</Panel>
|
||||
<Panel title="Organização e centros de custo">
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
department.post(
|
||||
'/empresa/departamentos',
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={department.errors} />
|
||||
<label className="block">
|
||||
Novo departamento
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
value={department.data.name}
|
||||
onChange={(e) =>
|
||||
department.setData(
|
||||
'name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={department.processing}>
|
||||
Adicionar departamento
|
||||
</Button>
|
||||
<p className="text-sm">
|
||||
{departments
|
||||
.map((d) => d.name)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</form>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
center.post(
|
||||
'/empresa/centros-de-custo',
|
||||
{ preserveScroll: true },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={center.errors} />
|
||||
<label className="block">
|
||||
Departamento
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={center.data.department_id}
|
||||
onChange={(e) =>
|
||||
center.setData(
|
||||
'department_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Escolher</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
Código
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
value={center.data.code}
|
||||
onChange={(e) =>
|
||||
center.setData(
|
||||
'code',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
Centro de custo
|
||||
<input
|
||||
required
|
||||
className={controlClass}
|
||||
value={center.data.name}
|
||||
onChange={(e) =>
|
||||
center.setData(
|
||||
'name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button disabled={center.processing}>
|
||||
Adicionar centro
|
||||
</Button>
|
||||
<p className="text-sm">
|
||||
{costCenters
|
||||
.map((c) => c.code + ' · ' + c.name)
|
||||
.join(' / ')}
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="Manutenção TEMPO">
|
||||
<p className="text-sm">
|
||||
Atribua a uma conta nominativa um perfil sem
|
||||
administração, assinale manutenção e defina a
|
||||
expiração. O acesso fica limitado a diagnóstico
|
||||
e consulta, é revogado removendo as atribuições
|
||||
e termina automaticamente na data indicada. As
|
||||
atribuições, alterações e operações mantêm
|
||||
autoria e auditoria; não existe palavra-passe
|
||||
universal.
|
||||
</p>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,17 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function Setup() {
|
||||
const form = useForm({ name: '', department: '' });
|
||||
const form = useForm({
|
||||
name: '',
|
||||
department: '',
|
||||
second_name: '',
|
||||
second_email: '',
|
||||
second_password: '',
|
||||
second_password_confirmation: '',
|
||||
first_limit: '',
|
||||
second_limit: '',
|
||||
includes_tax: true,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Head title="Configurar empresa" />
|
||||
@@ -13,7 +23,7 @@ export default function Setup() {
|
||||
<PageTitle
|
||||
eyebrow="Primeiros passos"
|
||||
title="A sua empresa"
|
||||
description="Crie a organização e o departamento inicial. A sua conta ficará como administradora desta empresa."
|
||||
description="Configure a organização e os dois administradores iniciais. A sua conta será a primeira; indique a segunda pessoa responsável pela administração."
|
||||
/>
|
||||
<Panel title="Dados da organização">
|
||||
<form
|
||||
@@ -53,10 +63,123 @@ export default function Setup() {
|
||||
placeholder="Ex.: Administração"
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="space-y-4 rounded border p-4">
|
||||
<legend className="px-2 font-medium">
|
||||
Segundo administrador da empresa
|
||||
</legend>
|
||||
<Label>
|
||||
Nome
|
||||
<Input
|
||||
required
|
||||
value={form.data.second_name}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'second_name',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<Label>
|
||||
Email
|
||||
<Input
|
||||
required
|
||||
type="email"
|
||||
value={form.data.second_email}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'second_email',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<Label>
|
||||
Palavra-passe inicial
|
||||
<Input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={12}
|
||||
value={form.data.second_password}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'second_password',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<Label>
|
||||
Confirmar palavra-passe
|
||||
<Input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={
|
||||
form.data.second_password_confirmation
|
||||
}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'second_password_confirmation',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</fieldset>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Será criado um centro de custo geral. Moeda inicial:
|
||||
MZN. Regras de aprovação provisórias.
|
||||
MZN. Defina os limites de aprovação da empresa.
|
||||
</p>
|
||||
<fieldset className="space-y-4 rounded border p-4">
|
||||
<legend className="px-2 font-medium">
|
||||
Regras da empresa
|
||||
</legend>
|
||||
<Label>
|
||||
Primeiro limite de aprovação (MZN)
|
||||
<Input
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+(\.[0-9]{1,2})?"
|
||||
value={form.data.first_limit}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'first_limit',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<Label>
|
||||
Segundo limite de aprovação (MZN)
|
||||
<Input
|
||||
required
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]+(\.[0-9]{1,2})?"
|
||||
value={form.data.second_limit}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'second_limit',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.includes_tax}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'includes_tax',
|
||||
e.target.checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Aplicar os limites ao valor com imposto
|
||||
</label>
|
||||
</fieldset>
|
||||
<Button disabled={form.processing}>
|
||||
Criar empresa
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCheck,
|
||||
@@ -30,6 +30,7 @@ export default function Dashboard({
|
||||
recent,
|
||||
approvals,
|
||||
}: Props) {
|
||||
const canRequest = usePage().props.canRequest === true;
|
||||
const cards = [
|
||||
{
|
||||
label: 'Requisições',
|
||||
@@ -65,23 +66,25 @@ export default function Dashboard({
|
||||
title="Visão geral das compras"
|
||||
description="Acompanhe os pedidos, as aprovações e a actividade da sua empresa."
|
||||
action={
|
||||
<Button
|
||||
asChild
|
||||
className="bg-teal-700 text-white hover:bg-teal-800"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
company
|
||||
? '/requisicoes/criar'
|
||||
: '/empresa/iniciar'
|
||||
}
|
||||
(!company || canRequest) && (
|
||||
<Button
|
||||
asChild
|
||||
className="bg-teal-700 text-white hover:bg-teal-800"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{company
|
||||
? 'Nova requisição'
|
||||
: 'Configurar empresa'}
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href={
|
||||
company
|
||||
? '/requisicoes/criar'
|
||||
: '/empresa/iniciar'
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{company
|
||||
? 'Nova requisição'
|
||||
: 'Configurar empresa'}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!company ? (
|
||||
|
||||
@@ -23,6 +23,7 @@ type Cancellation = {
|
||||
};
|
||||
type Props = {
|
||||
role: string;
|
||||
permissions: string[];
|
||||
actorId: number;
|
||||
policies: Policy[];
|
||||
reviews: {
|
||||
@@ -211,7 +212,7 @@ function RevisionForm({ id }: { id: number }) {
|
||||
}
|
||||
|
||||
export default function PurchaseControls({
|
||||
role,
|
||||
permissions,
|
||||
actorId,
|
||||
policies,
|
||||
reviews,
|
||||
@@ -233,7 +234,7 @@ export default function PurchaseControls({
|
||||
Um pedido pendente suspende decisões e emissão. Ordens,
|
||||
recepções, facturas e pagamentos exigem um circuito próprio.
|
||||
</p>
|
||||
{role === 'admin' && (
|
||||
{permissions.includes('admin') && (
|
||||
<Panel
|
||||
title="Regra da empresa"
|
||||
description="A publicação requer dois administradores distintos do proponente. A regra não vem activada por defeito."
|
||||
@@ -262,7 +263,7 @@ export default function PurchaseControls({
|
||||
}
|
||||
/2
|
||||
</p>
|
||||
{role === 'admin' &&
|
||||
{permissions.includes('admin') &&
|
||||
policy.status === 'pending' &&
|
||||
policy.id === policies[0]?.id &&
|
||||
policy.created_by !== actorId &&
|
||||
@@ -307,7 +308,7 @@ export default function PurchaseControls({
|
||||
{cancellation.review_reason}
|
||||
</p>
|
||||
)}
|
||||
{role === 'finance' &&
|
||||
{permissions.includes('finance') &&
|
||||
cancellation.status === 'pending' &&
|
||||
cancellation.created_by !== actorId && (
|
||||
<ReasonForm
|
||||
@@ -317,7 +318,7 @@ export default function PurchaseControls({
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
role === 'procurement' && (
|
||||
permissions.includes('procurement') && (
|
||||
<ReasonForm
|
||||
url={`/adjudicacoes/${award.id}/cancelamento`}
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,7 @@ type Review = {
|
||||
verdict: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
snapshot: { name: string; purchasing_role: string };
|
||||
snapshot: { name: string; purchasing_role: string; profile_label?: string };
|
||||
};
|
||||
type Decision = {
|
||||
id: number;
|
||||
@@ -373,9 +373,10 @@ export default function Admission({
|
||||
>
|
||||
<p>
|
||||
{r.snapshot.name} ·{' '}
|
||||
{roleLabels[
|
||||
r.snapshot.purchasing_role
|
||||
] ??
|
||||
{r.snapshot.profile_label ??
|
||||
roleLabels[
|
||||
r.snapshot.purchasing_role
|
||||
] ??
|
||||
r.snapshot.purchasing_role}{' '}
|
||||
·{' '}
|
||||
{r.verdict === 'approved'
|
||||
|
||||
@@ -38,7 +38,13 @@ type Assessment = {
|
||||
answers: Answer[];
|
||||
reason: string;
|
||||
created_at: string;
|
||||
snapshot: { analyst: { name: string; purchasing_role: string } };
|
||||
snapshot: {
|
||||
analyst: {
|
||||
name: string;
|
||||
purchasing_role: string;
|
||||
profile_label?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
type Props = {
|
||||
requisition: { id: number; number: string; justification: string };
|
||||
@@ -465,8 +471,9 @@ function AssessmentDetails({
|
||||
· {record.snapshot.analyst.name} · Análise v{record.revision}
|
||||
</summary>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{roleLabels[record.snapshot.analyst.purchasing_role]} ·{' '}
|
||||
{date(record.created_at)}
|
||||
{record.snapshot.analyst.profile_label ??
|
||||
roleLabels[record.snapshot.analyst.purchasing_role]}{' '}
|
||||
· {date(record.created_at)}
|
||||
</p>
|
||||
<p className="mt-3 text-sm whitespace-pre-wrap">{record.reason}</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
|
||||
@@ -36,7 +36,7 @@ type Review = {
|
||||
verdict: string;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
snapshot: { name: string; purchasing_role: string };
|
||||
snapshot: { name: string; purchasing_role: string; profile_label?: string };
|
||||
};
|
||||
type Attempt = {
|
||||
id: number;
|
||||
@@ -456,9 +456,11 @@ export default function Recommendation(props: Props) {
|
||||
>
|
||||
<p>
|
||||
{review.snapshot.name} ·{' '}
|
||||
{roleLabels[
|
||||
review.snapshot.purchasing_role
|
||||
] ??
|
||||
{review.snapshot.profile_label ??
|
||||
roleLabels[
|
||||
review.snapshot
|
||||
.purchasing_role
|
||||
] ??
|
||||
review.snapshot
|
||||
.purchasing_role}{' '}
|
||||
·{' '}
|
||||
|
||||
@@ -19,7 +19,7 @@ type Answer = {
|
||||
type Analysis = {
|
||||
id: number;
|
||||
revision: number;
|
||||
analyst: { name: string; purchasing_role: string };
|
||||
analyst: { name: string; purchasing_role: string; profile_label?: string };
|
||||
created_at: string;
|
||||
reason: string;
|
||||
answers: Answer[];
|
||||
@@ -93,6 +93,38 @@ type Report = {
|
||||
currency: string;
|
||||
total_minor: number;
|
||||
} | null;
|
||||
fulfilment: {
|
||||
receipts: {
|
||||
id: number;
|
||||
received_on: string | null;
|
||||
delivery_note: string;
|
||||
}[];
|
||||
finance_visible: boolean;
|
||||
invoices:
|
||||
| {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
issued_on: string;
|
||||
total_minor: string;
|
||||
currency: string | null;
|
||||
}[]
|
||||
| null;
|
||||
payments:
|
||||
| {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
status: string;
|
||||
paid_on: string;
|
||||
amount_minor: string;
|
||||
currency: string;
|
||||
}[]
|
||||
| null;
|
||||
};
|
||||
};
|
||||
const exactMoney = (minor: string, currency: string | null) => {
|
||||
const digits = minor.padStart(3, '0');
|
||||
return `${digits.slice(0, -2)},${digits.slice(-2)} ${currency ?? '(moeda não documentada)'}`;
|
||||
};
|
||||
const money = (minor: number, currency: string) =>
|
||||
`${(minor / 100).toLocaleString('pt-PT', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`;
|
||||
@@ -139,7 +171,12 @@ export default function ReviewReport({ report }: { report: Report }) {
|
||||
title="Relatório de conferência"
|
||||
description={`${report.company} · ${r.department} · Solicitante: ${r.requester}`}
|
||||
action={
|
||||
<div className="flex gap-3 print:hidden">
|
||||
<div className="flex flex-wrap gap-3 print:hidden">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/relatorios">
|
||||
Relatórios de compras
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/requisicoes/${r.id}/processo`}>
|
||||
Ver processo
|
||||
@@ -415,13 +452,13 @@ export default function ReviewReport({ report }: { report: Report }) {
|
||||
.name
|
||||
}{' '}
|
||||
·{' '}
|
||||
{
|
||||
{analysis.analyst
|
||||
.profile_label ??
|
||||
roleLabels[
|
||||
analysis
|
||||
.analyst
|
||||
.purchasing_role
|
||||
]
|
||||
}{' '}
|
||||
]}{' '}
|
||||
· Análise v
|
||||
{analysis.revision}
|
||||
</h4>
|
||||
@@ -611,6 +648,131 @@ export default function ReviewReport({ report }: { report: Report }) {
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Recepção, facturas e registos de pagamento"
|
||||
description="Factos registados em cada etapa. A presença destes documentos não confirma entrega integral, liquidação ou execução bancária."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section aria-label="Recepções do processo">
|
||||
<h3 className="font-medium">Recepções</h3>
|
||||
{report.fulfilment.receipts.length === 0 ? (
|
||||
<p>Sem recepções registadas.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{report.fulfilment.receipts.map(
|
||||
(receipt) => (
|
||||
<li key={receipt.id}>
|
||||
Recepção #{receipt.id} ·{' '}
|
||||
{receipt.received_on
|
||||
? date(receipt.received_on)
|
||||
: 'Data por indicar'}{' '}
|
||||
· Guia: {receipt.delivery_note}.{' '}
|
||||
<Link
|
||||
className="underline print:hidden"
|
||||
href={`/recepcoes/${receipt.id}`}
|
||||
>
|
||||
Consultar recepção
|
||||
</Link>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{!report.fulfilment.finance_visible ? (
|
||||
<p>
|
||||
Facturas e pagamentos estão disponíveis aos
|
||||
perfis autorizados de Finanças e Administração.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<section aria-label="Facturas do processo">
|
||||
<h3 className="font-medium">Facturas</h3>
|
||||
{!report.fulfilment.invoices?.length ? (
|
||||
<p>Sem facturas registadas.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{report.fulfilment.invoices.map(
|
||||
(invoice) => (
|
||||
<li key={invoice.id}>
|
||||
Factura {invoice.number}{' '}
|
||||
·{' '}
|
||||
{date(
|
||||
invoice.issued_on,
|
||||
)}{' '}
|
||||
·{' '}
|
||||
{statusLabels[
|
||||
invoice.status
|
||||
] ??
|
||||
invoice.status}{' '}
|
||||
· Total documental:{' '}
|
||||
{exactMoney(
|
||||
invoice.total_minor,
|
||||
invoice.currency,
|
||||
)}
|
||||
.
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="Pagamentos externos do processo">
|
||||
<h3 className="font-medium">
|
||||
Registos de pagamentos externos
|
||||
</h3>
|
||||
{!report.fulfilment.payments?.length ? (
|
||||
<p>
|
||||
Sem registos de pagamentos externos.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{report.fulfilment.payments.map(
|
||||
(payment) => (
|
||||
<li key={payment.id}>
|
||||
Registo #{payment.id} ·
|
||||
Factura{' '}
|
||||
{report.fulfilment.invoices?.find(
|
||||
(invoice) =>
|
||||
invoice.id ===
|
||||
payment.invoice_id,
|
||||
)?.number ??
|
||||
`#${payment.invoice_id}`}{' '}
|
||||
·{' '}
|
||||
{date(payment.paid_on)}{' '}
|
||||
·{' '}
|
||||
{{
|
||||
pending:
|
||||
'Por conferir',
|
||||
confirmed:
|
||||
'Confirmado',
|
||||
rejected:
|
||||
'Rejeitado',
|
||||
}[payment.status] ??
|
||||
payment.status}{' '}
|
||||
·{' '}
|
||||
{exactMoney(
|
||||
payment.amount_minor,
|
||||
payment.currency,
|
||||
)}
|
||||
.
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Os montantes são os registos
|
||||
preservados, sem cálculo de saldo neste
|
||||
relatório. Obrigações e afectações do
|
||||
núcleo comum têm o seu próprio
|
||||
histórico.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
<footer className="border-t pt-4 text-xs text-muted-foreground">
|
||||
SGC · Sistema de Gestão de Compras · {r.number} · Consulta
|
||||
de {generated}, Africa/Johannesburg (UTC+2). Decisão formal
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Download } from 'lucide-react';
|
||||
import {
|
||||
PageTitle,
|
||||
Panel,
|
||||
Status,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { date, statusLabels } from '@/types/procurement';
|
||||
|
||||
type Row = {
|
||||
id: number;
|
||||
number: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
department: string | null;
|
||||
requester: string | null;
|
||||
total_minor: string;
|
||||
quotations_count: number;
|
||||
order_number: string | null;
|
||||
};
|
||||
type Filters = {
|
||||
search?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
department_id?: string | number;
|
||||
};
|
||||
const estimate = (minor: string) => {
|
||||
const digits = minor.padStart(3, '0');
|
||||
return `${digits.slice(0, -2)},${digits.slice(-2)} MZN`;
|
||||
};
|
||||
export default function ProcurementReports({
|
||||
records,
|
||||
counts,
|
||||
filters,
|
||||
departments,
|
||||
generatedAt,
|
||||
}: {
|
||||
records: {
|
||||
data: Row[];
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
counts: Record<string, number>;
|
||||
filters: Filters;
|
||||
departments: { id: number; name: string }[];
|
||||
generatedAt: string;
|
||||
}) {
|
||||
const form = useForm({
|
||||
search: filters.search ?? '',
|
||||
status: filters.status ?? '',
|
||||
from: filters.from ?? '',
|
||||
to: filters.to ?? '',
|
||||
department_id: String(filters.department_id ?? ''),
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters))
|
||||
if (value !== null && value !== undefined && value !== '')
|
||||
params.set(key, String(value));
|
||||
return (
|
||||
<>
|
||||
<Head title="Relatórios de compras" />
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-5 py-8 lg:px-8">
|
||||
<PageTitle
|
||||
eyebrow="Consulta e relatórios"
|
||||
title="Relatórios de compras"
|
||||
description="Localize o pedido, acompanhe o processo e abra o relatório de conferência com as decisões e os documentos disponíveis."
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/relatorios/compras.csv?${params}`}>
|
||||
<Download className="size-4" />
|
||||
Exportar CSV
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Panel
|
||||
title="Filtrar requisições"
|
||||
description="O período refere-se à criação da requisição. Os resultados respeitam o seu acesso à empresa e ao departamento."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.get('/relatorios', { preserveState: true });
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Número da requisição
|
||||
<input
|
||||
className={controlClass}
|
||||
value={form.data.search}
|
||||
onChange={(event) =>
|
||||
form.setData('search', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado da requisição
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.status}
|
||||
onChange={(event) =>
|
||||
form.setData('status', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(statusLabels).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Departamento
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.department_id}
|
||||
onChange={(event) =>
|
||||
form.setData(
|
||||
'department_id',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os autorizados</option>
|
||||
{departments.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Criada desde
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.from}
|
||||
onChange={(event) =>
|
||||
form.setData('from', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Criada até
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.to}
|
||||
onChange={(event) =>
|
||||
form.setData('to', event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end gap-3">
|
||||
<Button type="submit" disabled={form.processing}>
|
||||
Aplicar filtros
|
||||
</Button>
|
||||
<Link
|
||||
className="py-2 text-sm underline"
|
||||
href="/relatorios"
|
||||
>
|
||||
Limpar
|
||||
</Link>
|
||||
</div>
|
||||
{Object.entries(form.errors).map(([key, message]) => (
|
||||
<p
|
||||
key={key}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</form>
|
||||
</Panel>
|
||||
<section
|
||||
aria-label="Resumo dos resultados"
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<p className="rounded-lg border bg-card px-4 py-3 font-medium">
|
||||
{records.total} requisições
|
||||
</p>
|
||||
{Object.entries(counts).map(([status, count]) => (
|
||||
<p
|
||||
key={status}
|
||||
className="rounded-lg border bg-card px-4 py-3 text-sm"
|
||||
>
|
||||
{statusLabels[status] ?? status}: {count}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
<Panel
|
||||
title="Do pedido ao relatório"
|
||||
description="A estimativa da requisição, a adjudicação, a recepção e o pagamento têm estados distintos. Propostas registadas não significam propostas elegíveis; uma ordem emitida não comprova liquidação."
|
||||
>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
O CSV inclui todos os resultados dos filtros aplicados,
|
||||
não apenas esta página. Consulta gerada em{' '}
|
||||
{new Date(generatedAt).toLocaleString('pt-MZ')}.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-240 text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Requisição</th>
|
||||
<th className="p-2">
|
||||
Departamento / solicitante
|
||||
</th>
|
||||
<th className="p-2">Estado</th>
|
||||
<th className="p-2">Estimativa total</th>
|
||||
<th className="p-2">Propostas / ordem</th>
|
||||
<th className="p-2">Continuar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.data.map((row) => (
|
||||
<tr key={row.id} className="border-t">
|
||||
<td className="p-2">
|
||||
<Link
|
||||
className="font-medium underline"
|
||||
href={`/requisicoes/${row.id}`}
|
||||
>
|
||||
{row.number}
|
||||
</Link>
|
||||
<span className="block text-muted-foreground">
|
||||
{date(row.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{row.department ??
|
||||
'Departamento indisponível'}
|
||||
<span className="block text-muted-foreground">
|
||||
{row.requester ??
|
||||
'Solicitante indisponível'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Status value={row.status} />
|
||||
</td>
|
||||
<td className="p-2 whitespace-nowrap">
|
||||
{estimate(row.total_minor)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{row.quotations_count} propostas
|
||||
<span className="block text-muted-foreground">
|
||||
{row.order_number ??
|
||||
'Sem ordem de compra'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="space-y-2 p-2">
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/requisicoes/${row.id}/processo`}
|
||||
>
|
||||
Acompanhar processo
|
||||
</Link>
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/requisicoes/${row.id}/relatorio`}
|
||||
>
|
||||
Gerar relatório de conferência
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{records.data.length === 0 && (
|
||||
<p className="py-6 text-sm">
|
||||
Nenhuma requisição encontrada nos filtros e no
|
||||
âmbito autorizado.
|
||||
</p>
|
||||
)}
|
||||
<nav
|
||||
aria-label="Paginação dos relatórios"
|
||||
className="mt-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm">
|
||||
Página {records.current_page} de {records.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{records.prev_page_url && (
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={records.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{records.next_page_url && (
|
||||
<Link
|
||||
className="text-sm underline"
|
||||
href={records.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type Filters = {
|
||||
supplier_id?: string;
|
||||
responsible_id?: string;
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
overdue?: string;
|
||||
search?: string;
|
||||
};
|
||||
type Row = {
|
||||
id: number;
|
||||
evaluation_id: number;
|
||||
revision: number;
|
||||
supplier: string;
|
||||
responsible: string;
|
||||
title: string;
|
||||
description: string;
|
||||
due_on: string;
|
||||
status: string;
|
||||
version: number;
|
||||
overdue: boolean;
|
||||
result: string | null;
|
||||
evidence: string | null;
|
||||
review_reason: string | null;
|
||||
};
|
||||
const states: Record<string, string> = {
|
||||
open: 'Em execução',
|
||||
review: 'A aguardar conferência',
|
||||
returned: 'Devolvida para correcção',
|
||||
closed: 'Encerrada',
|
||||
};
|
||||
const base = '/relatorios/medidas-fornecedores';
|
||||
function query(filters: Filters) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters))
|
||||
if (value) search.set(key, String(value));
|
||||
return search.size ? '?' + search.toString() : '';
|
||||
}
|
||||
export default function SupplierImprovementReport({
|
||||
records,
|
||||
filters,
|
||||
counts,
|
||||
overdue,
|
||||
suppliers,
|
||||
responsibles,
|
||||
today,
|
||||
generatedAt,
|
||||
}: {
|
||||
records: {
|
||||
data: Row[];
|
||||
total: number;
|
||||
from: number | null;
|
||||
to: number | null;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
filters: Filters;
|
||||
counts: Record<string, number>;
|
||||
overdue: number;
|
||||
suppliers: { id: number; name: string }[];
|
||||
responsibles: { id: number; name: string }[];
|
||||
today: string;
|
||||
generatedAt: string;
|
||||
}) {
|
||||
const form = useForm({
|
||||
supplier_id: filters.supplier_id ?? '',
|
||||
responsible_id: filters.responsible_id ?? '',
|
||||
status: filters.status ?? '',
|
||||
from: filters.from ?? '',
|
||||
to: filters.to ?? '',
|
||||
overdue: filters.overdue ?? '',
|
||||
search: filters.search ?? '',
|
||||
});
|
||||
const applied = query(filters);
|
||||
return (
|
||||
<>
|
||||
<Head title="Medidas de melhoria de fornecedores" />
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-5 py-8 lg:px-8">
|
||||
<PageTitle
|
||||
eyebrow="Relatórios · Fornecedores"
|
||||
title="Medidas de melhoria"
|
||||
description="Acompanhar responsáveis, prazos, execução e encerramentos em todas as avaliações autorizadas."
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3 print:hidden">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/fornecedores/avaliacoes">
|
||||
Avaliações
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a href={base + '.csv' + applied}>
|
||||
Exportar todos os resultados (CSV)
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
Imprimir esta página
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Panel title="Filtros do acompanhamento">
|
||||
<form
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 print:hidden"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.get(base, {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="space-y-1">
|
||||
Fornecedor
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.supplier_id}
|
||||
onChange={(e) =>
|
||||
form.setData('supplier_id', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os autorizados</option>
|
||||
{suppliers.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Responsável
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.responsible_id}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'responsible_id',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os autorizados</option>
|
||||
{responsibles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.status}
|
||||
onChange={(e) =>
|
||||
form.setData('status', e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(states).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Título da medida
|
||||
<input
|
||||
className={controlClass}
|
||||
maxLength={200}
|
||||
value={form.data.search}
|
||||
onChange={(e) =>
|
||||
form.setData('search', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Prazo a partir de
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.from}
|
||||
onChange={(e) =>
|
||||
form.setData('from', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
Prazo até
|
||||
<input
|
||||
type="date"
|
||||
className={controlClass}
|
||||
value={form.data.to}
|
||||
onChange={(e) =>
|
||||
form.setData('to', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 self-end pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.data.overdue === '1'}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'overdue',
|
||||
e.target.checked ? '1' : '',
|
||||
)
|
||||
}
|
||||
/>
|
||||
Só prazos ultrapassados
|
||||
</label>
|
||||
<div className="flex items-end gap-3">
|
||||
<Button disabled={form.processing}>Aplicar</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={base}>Limpar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(form.errors).length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-sm text-red-700 sm:col-span-2 lg:col-span-4"
|
||||
>
|
||||
{Object.entries(form.errors).map(
|
||||
([key, error]) => (
|
||||
<p key={key}>{error}</p>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Data de referência: {today} · Consulta:{' '}
|
||||
{new Date(generatedAt).toLocaleString('pt-MZ')}. O
|
||||
resumo e o CSV usam os filtros aplicados. Prazo
|
||||
ultrapassado significa medida ainda não encerrada com
|
||||
prazo anterior à data de referência.
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Filtros aplicados:{' '}
|
||||
{suppliers.find(
|
||||
(s) => String(s.id) === String(filters.supplier_id),
|
||||
)?.name ??
|
||||
(filters.supplier_id
|
||||
? `Fornecedor #${filters.supplier_id}`
|
||||
: 'todos os fornecedores')}
|
||||
;{' '}
|
||||
{responsibles.find(
|
||||
(p) =>
|
||||
String(p.id) === String(filters.responsible_id),
|
||||
)?.name ??
|
||||
(filters.responsible_id
|
||||
? `Responsável #${filters.responsible_id}`
|
||||
: 'todos os responsáveis')}
|
||||
; {states[filters.status ?? ''] ?? 'todos os estados'};
|
||||
prazo {filters.from || 'sem início'} a{' '}
|
||||
{filters.to || 'sem fim'}
|
||||
{filters.overdue === '1' ? '; só ultrapassados' : ''}
|
||||
{filters.search ? `; título: ${filters.search}` : ''}.
|
||||
</p>
|
||||
</Panel>
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{[
|
||||
['Resultados', records.total],
|
||||
...Object.entries(states).map(([key, label]) => [
|
||||
label,
|
||||
counts[key] ?? 0,
|
||||
]),
|
||||
['Prazo ultrapassado', overdue],
|
||||
].map(([label, count]) => (
|
||||
<div key={String(label)} className="rounded border p-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{count}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Panel title="Medidas dos filtros aplicados">
|
||||
<p className="mb-4 text-sm">
|
||||
A mostrar {records.from ?? 0}–{records.to ?? 0} de{' '}
|
||||
{records.total}. Página {records.current_page} de{' '}
|
||||
{records.last_page}. O CSV inclui todos os resultados,
|
||||
incluindo medidas de revisões anteriores.
|
||||
</p>
|
||||
{records.data.length === 0 ? (
|
||||
<p className="text-sm">
|
||||
Não existem medidas autorizadas para estes filtros.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="p-3">
|
||||
Medida e fornecedor
|
||||
</th>
|
||||
<th className="p-3">
|
||||
Responsável e prazo
|
||||
</th>
|
||||
<th className="p-3">
|
||||
Estado e acompanhamento
|
||||
</th>
|
||||
<th className="p-3 print:hidden">
|
||||
Percurso
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.data.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b align-top"
|
||||
>
|
||||
<td className="p-3">
|
||||
<p className="font-medium">
|
||||
MM-{row.id} · {row.title}
|
||||
</p>
|
||||
<p>{row.supplier}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AVF-{row.evaluation_id} ·
|
||||
Revisão {row.revision}
|
||||
</p>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<p>{row.responsible}</p>
|
||||
<p>{row.due_on}</p>
|
||||
{row.overdue && (
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">
|
||||
Prazo ultrapassado
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-md space-y-2 p-3">
|
||||
<p className="font-medium">
|
||||
{states[row.status]} ·
|
||||
Versão {row.version}
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer underline">
|
||||
Resultado e evidência
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 whitespace-pre-wrap">
|
||||
<p>{row.description}</p>
|
||||
<p>
|
||||
{row.result ||
|
||||
'Sem resultado registado.'}
|
||||
</p>
|
||||
<p>
|
||||
{row.evidence ||
|
||||
'Sem evidência registada.'}
|
||||
</p>
|
||||
{row.review_reason && (
|
||||
<p>
|
||||
Última
|
||||
conferência:{' '}
|
||||
{
|
||||
row.review_reason
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</td>
|
||||
<td className="space-y-2 p-3 print:hidden">
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/fornecedores/avaliacoes/${row.evaluation_id}#medida-${row.id}`}
|
||||
>
|
||||
Avaliação e acompanhamento
|
||||
</Link>
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/fornecedores/avaliacoes/${row.evaluation_id}/relatorio#medida-${row.id}`}
|
||||
>
|
||||
Relatório individual
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<nav
|
||||
aria-label="Paginação das medidas"
|
||||
className="mt-4 flex gap-4 text-sm print:hidden"
|
||||
>
|
||||
{records.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={records.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{records.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={records.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</Panel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Consulta de acompanhamento. Não altera avaliações aprovadas,
|
||||
elegibilidade, pagamentos ou saldos. Nomes de fornecedores
|
||||
preservados nas avaliações; responsáveis apresentados
|
||||
segundo a identificação actual autorizada.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import {
|
||||
PageTitle,
|
||||
@@ -23,6 +23,7 @@ type Props = {
|
||||
filters: { search?: string; status?: string; view?: string };
|
||||
};
|
||||
export default function Index({ requisitions, filters }: Props) {
|
||||
const canRequest = usePage().props.canRequest === true;
|
||||
const form = useForm({
|
||||
search: filters.search ?? '',
|
||||
status: filters.status ?? '',
|
||||
@@ -63,12 +64,14 @@ export default function Index({ requisitions, filters }: Props) {
|
||||
title={view.title}
|
||||
description={view.description}
|
||||
action={
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
canRequest && (
|
||||
<Button asChild>
|
||||
<Link href="/requisicoes/criar">
|
||||
<Plus className="size-4" />
|
||||
Nova requisição
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Panel title="Pedidos de compra">
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { Printer } from 'lucide-react';
|
||||
import SupplierImprovements from '@/components/supplier-improvements';
|
||||
import type { ImprovementData } from '@/components/supplier-improvements';
|
||||
import SupplierResponses from '@/components/supplier-responses';
|
||||
import type { ResponseData } from '@/components/supplier-responses';
|
||||
import { PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplierEvaluationFields, {
|
||||
evaluationStates,
|
||||
criterionStates,
|
||||
} from '@/components/supplier-evaluation-fields';
|
||||
import type {
|
||||
EvaluationPayload,
|
||||
EvaluationOrder,
|
||||
} from '@/components/supplier-evaluation-fields';
|
||||
|
||||
type Evaluation = {
|
||||
id: number;
|
||||
supplier_id: number;
|
||||
revision: number;
|
||||
version: number;
|
||||
status: string;
|
||||
previous_id: number | null;
|
||||
created_by: number;
|
||||
contributors: number[];
|
||||
supplier_snapshot: { id: number; name: string; nuit: string };
|
||||
payload: EvaluationPayload;
|
||||
review_reason: string | null;
|
||||
reviewed_at: string | null;
|
||||
};
|
||||
type History = {
|
||||
id: number;
|
||||
action: string;
|
||||
user_id: number;
|
||||
after: Evaluation;
|
||||
created_at: string;
|
||||
};
|
||||
const actions: Record<string, string> = {
|
||||
created: 'Criação',
|
||||
updated: 'Alteração',
|
||||
submit: 'Submissão',
|
||||
approve: 'Aprovação',
|
||||
return: 'Devolução',
|
||||
revised: 'Nova revisão',
|
||||
};
|
||||
export default function SupplierEvaluation({
|
||||
evaluation: e,
|
||||
orders,
|
||||
canEdit,
|
||||
canReview,
|
||||
canRevise,
|
||||
reportOnly,
|
||||
nextRevisionId,
|
||||
history,
|
||||
people,
|
||||
notice,
|
||||
generatedAt,
|
||||
improvements,
|
||||
responses,
|
||||
}: {
|
||||
evaluation: Evaluation;
|
||||
orders: EvaluationOrder[];
|
||||
canEdit: boolean;
|
||||
canReview: boolean;
|
||||
canRevise: boolean;
|
||||
reportOnly: boolean;
|
||||
nextRevisionId: number | null;
|
||||
history: {
|
||||
data: History[];
|
||||
total: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
people: Record<number, string>;
|
||||
notice?: string;
|
||||
generatedAt: string;
|
||||
improvements: ImprovementData;
|
||||
responses: ResponseData;
|
||||
}) {
|
||||
const form = useForm({ version: e.version, payload: e.payload });
|
||||
const command = useForm({ version: e.version, reason: '' });
|
||||
const base = `/fornecedores/avaliacoes/${e.id}`;
|
||||
const dirty =
|
||||
JSON.stringify(form.data.payload) !== JSON.stringify(e.payload);
|
||||
const waiting = e.payload.criteria.filter((row) =>
|
||||
['pending', 'insufficient'].includes(row.result),
|
||||
).length;
|
||||
const submit = (action: string) =>
|
||||
command.post(`${base}/${action}`, {
|
||||
preserveScroll: true,
|
||||
preserveState: 'errors',
|
||||
});
|
||||
const errors = [
|
||||
...Object.values(form.errors),
|
||||
...Object.values(command.errors),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Head title={`AVF-${e.id} · Avaliação de fornecedor`} />
|
||||
<style>{`@media print { @page {size:A4;margin:14mm} .evaluation-report {font-size:11px;max-width:none;padding:0} .evaluation-report section {box-shadow:none} .evaluation-report h2,.evaluation-report h3 {break-after:avoid} .evaluation-report article {break-inside:avoid} }`}</style>
|
||||
<div className="evaluation-report mx-auto max-w-5xl space-y-6 px-5 py-8 lg:px-8">
|
||||
<PageTitle
|
||||
eyebrow={`AVF-${e.id} · Revisão ${e.revision} · Versão ${e.version}`}
|
||||
title={e.payload.title}
|
||||
description={`${e.supplier_snapshot.name} · ${evaluationStates[e.status] ?? e.status}`}
|
||||
action={
|
||||
<div className="flex flex-wrap gap-3 print:hidden">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/fornecedores/avaliacoes">
|
||||
Avaliações
|
||||
</Link>
|
||||
</Button>
|
||||
{!reportOnly && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`${base}/relatorio`}>
|
||||
Abrir relatório
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{reportOnly && (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={base}>
|
||||
Voltar à avaliação
|
||||
</Link>
|
||||
</Button>
|
||||
<Button onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-teal-300 p-3 print:hidden"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<span>
|
||||
Autor: {people[e.created_by] ?? `#${e.created_by}`}
|
||||
</span>
|
||||
<span>NUIT preservado: {e.supplier_snapshot.nuit}</span>
|
||||
{e.previous_id && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fornecedores/avaliacoes/${e.previous_id}/relatorio`}
|
||||
>
|
||||
Revisão anterior preservada
|
||||
</Link>
|
||||
)}
|
||||
{nextRevisionId && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={`/fornecedores/avaliacoes/${nextRevisionId}`}
|
||||
>
|
||||
Abrir revisão posterior
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{e.status !== 'approved' && (
|
||||
<p className="rounded border border-amber-300 p-3 text-sm">
|
||||
{evaluationStates[e.status]} — este documento ainda não
|
||||
contém uma aprovação independente.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Registo qualitativo da prestação da entidade fornecedora.
|
||||
Sem pontuação ou classificação automática; não altera por si
|
||||
só a qualificação, suspensão ou elegibilidade do fornecedor.
|
||||
</p>
|
||||
{canEdit ? (
|
||||
<Panel title="Preparar e conservar evidências">
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.put(base, {
|
||||
preserveScroll: true,
|
||||
preserveState: 'errors',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SupplierEvaluationFields
|
||||
payload={form.data.payload}
|
||||
orders={orders}
|
||||
disabled={form.processing || command.processing}
|
||||
onChange={(payload) =>
|
||||
form.setData('payload', payload)
|
||||
}
|
||||
/>
|
||||
{errors.map((error, index) => (
|
||||
<p
|
||||
key={index}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
disabled={
|
||||
form.processing || command.processing
|
||||
}
|
||||
>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={
|
||||
form.processing ||
|
||||
command.processing ||
|
||||
dirty
|
||||
}
|
||||
onClick={() => submit('submit')}
|
||||
>
|
||||
Submeter para revisão
|
||||
</Button>
|
||||
</div>
|
||||
{dirty && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Guarde as alterações antes de submeter. A
|
||||
revisão compara os dados efectivamente
|
||||
guardados.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<Panel title="Âmbito e evidências">
|
||||
<div className="space-y-4 text-sm">
|
||||
<p>
|
||||
Componente:{' '}
|
||||
{e.payload.nature === 'goods'
|
||||
? 'Bens'
|
||||
: 'Serviços'}{' '}
|
||||
· Período:{' '}
|
||||
{e.payload.period_start ?? 'Por indicar'} —{' '}
|
||||
{e.payload.period_end ?? 'Por indicar'}.
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{e.payload.scope ||
|
||||
'Âmbito ainda não indicado.'}
|
||||
</p>
|
||||
<div>
|
||||
Ordens abrangidas:{' '}
|
||||
{e.payload.order_ids.length
|
||||
? e.payload.order_ids.map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
className="mr-3 underline"
|
||||
href={`/ordens/${id}`}
|
||||
>
|
||||
{orders.find(
|
||||
(order) =>
|
||||
order.id === id,
|
||||
)?.number ?? `#${id}`}
|
||||
</Link>
|
||||
))
|
||||
: 'Sem ordens associadas; conferir as referências declaradas no âmbito.'}
|
||||
</div>
|
||||
<p>
|
||||
{e.payload.criteria.length} critérios ·{' '}
|
||||
{waiting} por analisar ou com evidência
|
||||
insuficiente.
|
||||
</p>
|
||||
{e.payload.criteria.map((row, index) => (
|
||||
<article
|
||||
key={index}
|
||||
className="space-y-2 rounded border p-3"
|
||||
>
|
||||
<h3 className="font-medium">
|
||||
{index + 1}.{' '}
|
||||
{row.label ||
|
||||
'Critério por identificar'}
|
||||
</h3>
|
||||
<p>{criterionStates[row.result]}</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
Justificação:{' '}
|
||||
{row.reason || 'Por completar'}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
Evidência declarada:{' '}
|
||||
{row.evidence || 'Não indicada'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
<h3 className="font-medium">Conclusão</h3>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{e.payload.conclusion ||
|
||||
'Ainda não registada.'}
|
||||
</p>
|
||||
<h3 className="font-medium">
|
||||
Ocorrências críticas
|
||||
</h3>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{e.payload.critical_occurrences ||
|
||||
'Sem informação registada neste campo; não equivale a uma declaração de ausência de ocorrências.'}
|
||||
</p>
|
||||
{e.payload.critical_evidence && (
|
||||
<p className="whitespace-pre-wrap">
|
||||
Evidência: {e.payload.critical_evidence}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
{e.review_reason && (
|
||||
<Panel title="Última decisão documentada">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{e.review_reason}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{e.reviewed_at}
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
{canReview && (
|
||||
<Panel title="Conferência independente">
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<p className="text-sm">
|
||||
Confirme âmbito, atribuição das ocorrências,
|
||||
aplicabilidade e referências. Evidências
|
||||
pendentes não são convertidas em notas nem
|
||||
ignoradas.
|
||||
</p>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fundamento da decisão
|
||||
<textarea
|
||||
className={
|
||||
controlClass + ' h-auto min-h-24 py-2'
|
||||
}
|
||||
value={command.data.reason}
|
||||
minLength={10}
|
||||
maxLength={4000}
|
||||
onChange={(event) =>
|
||||
command.setData(
|
||||
'reason',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{errors.map((error, index) => (
|
||||
<p
|
||||
key={index}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={command.processing || waiting > 0}
|
||||
onClick={() => submit('approve')}
|
||||
>
|
||||
Aprovar avaliação
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={command.processing}
|
||||
onClick={() => submit('return')}
|
||||
>
|
||||
Devolver para correcção
|
||||
</Button>
|
||||
</div>
|
||||
{waiting > 0 && (
|
||||
<p className="text-sm text-amber-800">
|
||||
Existem {waiting} critérios pendentes.
|
||||
Devolva para completar as evidências antes
|
||||
de aprovar.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{e.status === 'review' && !canReview && !reportOnly && (
|
||||
<p className="text-sm">
|
||||
Aguarda conferência por outra pessoa autorizada que não
|
||||
tenha elaborado ou submetido esta revisão.
|
||||
</p>
|
||||
)}
|
||||
{canRevise && (
|
||||
<Panel title="Rectificar sem apagar a avaliação aprovada">
|
||||
<p className="mb-3 text-sm">
|
||||
A rectificação cria outro rascunho, com número de
|
||||
revisão próprio. A versão aprovada anterior mantém o
|
||||
seu relatório e histórico.
|
||||
</p>
|
||||
<Button
|
||||
disabled={command.processing}
|
||||
onClick={() => submit('rever')}
|
||||
>
|
||||
Preparar nova revisão
|
||||
</Button>
|
||||
{errors.map((error, index) => (
|
||||
<p
|
||||
key={index}
|
||||
role="alert"
|
||||
className="mt-2 text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</Panel>
|
||||
)}
|
||||
{!reportOnly && (
|
||||
<Panel title="Histórico de alterações e decisões">
|
||||
<p className="mb-3 text-sm">
|
||||
{history.total} registos preservados. Cada alteração
|
||||
conserva a sua fotografia; versões aprovadas não são
|
||||
reescritas.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{history.data.map((event) => (
|
||||
<details
|
||||
key={event.id}
|
||||
className="rounded border p-3 text-sm"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{actions[
|
||||
event.action.split('.').at(-1) ?? ''
|
||||
] ?? event.action}{' '}
|
||||
· Versão {event.after.version} ·{' '}
|
||||
{people[event.user_id] ??
|
||||
`#${event.user_id}`}{' '}
|
||||
·{' '}
|
||||
{new Date(
|
||||
event.created_at,
|
||||
).toLocaleString('pt-MZ')}
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<p>
|
||||
{
|
||||
evaluationStates[
|
||||
event.after.status
|
||||
]
|
||||
}{' '}
|
||||
· {event.after.payload.title}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{event.after.payload.scope}
|
||||
</p>
|
||||
{event.after.payload.criteria.map(
|
||||
(row, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="whitespace-pre-wrap"
|
||||
>
|
||||
{row.label} ·{' '}
|
||||
{
|
||||
criterionStates[
|
||||
row.result
|
||||
]
|
||||
}{' '}
|
||||
· {row.reason} · Evidência:{' '}
|
||||
{row.evidence ||
|
||||
'Não indicada'}
|
||||
</p>
|
||||
),
|
||||
)}
|
||||
<p className="whitespace-pre-wrap">
|
||||
{event.after.payload.conclusion}
|
||||
</p>
|
||||
{event.after.review_reason && (
|
||||
<p className="whitespace-pre-wrap">
|
||||
Decisão:{' '}
|
||||
{event.after.review_reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<nav
|
||||
aria-label="Paginação do histórico"
|
||||
className="mt-4 flex gap-4 text-sm"
|
||||
>
|
||||
{history.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={history.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{history.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={history.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</Panel>
|
||||
)}
|
||||
<SupplierImprovements
|
||||
data={improvements}
|
||||
evaluationId={e.id}
|
||||
names={people}
|
||||
reportOnly={reportOnly}
|
||||
generatedAt={generatedAt}
|
||||
/>
|
||||
<SupplierResponses
|
||||
data={responses}
|
||||
evaluationId={e.id}
|
||||
names={people}
|
||||
reportOnly={reportOnly}
|
||||
generatedAt={generatedAt}
|
||||
/>
|
||||
<footer className="border-t pt-4 text-xs text-muted-foreground">
|
||||
SGC · AVF-{e.id} · Revisão {e.revision} ·{' '}
|
||||
{evaluationStates[e.status]} · Consulta de{' '}
|
||||
{new Date(generatedAt).toLocaleString('pt-MZ')}. As
|
||||
referências são declarações documentadas, não verificação
|
||||
automática do conteúdo.
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,349 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { PageTitle, Panel } from '@/components/procurement';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { PageTitle, Panel, controlClass } from '@/components/procurement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import SupplierEvaluationFields, {
|
||||
emptyEvaluation,
|
||||
evaluationStates,
|
||||
} from '@/components/supplier-evaluation-fields';
|
||||
import type { EvaluationOrder } from '@/components/supplier-evaluation-fields';
|
||||
import proposal from './evaluation-proposal';
|
||||
|
||||
const areas = [
|
||||
['Avaliações planeadas', 'Definir fornecedor, período, âmbito e responsáveis pela avaliação.'],
|
||||
['Nova avaliação', 'Identificar contratos e ordens abrangidas, escolher a grelha de bens ou serviços e reunir notas, justificações e evidências.'],
|
||||
['Rascunhos', 'Preparar a avaliação e completar evidências antes de submeter para revisão.'],
|
||||
['Avaliações em revisão', 'Conferência independente, com devolução para correcção ou aprovação documentada.'],
|
||||
['Avaliações aprovadas', 'Consultar versões aprovadas; rectificações devem gerar revisão identificável.'],
|
||||
['Histórico por fornecedor', 'Consultar a evolução, observações e contestações com decisão documentada. Comparar apenas âmbitos e grelhas equivalentes.'],
|
||||
['Planos de melhoria', 'Acompanhar acções, responsáveis, prazos, evidências de conclusão e validação de eficácia.'],
|
||||
['Relatórios de avaliação', 'Ficha por fornecedor e período, resultados por critério, ocorrências críticas e cobertura de evidências.'],
|
||||
];
|
||||
export default function SupplierEvaluations() {
|
||||
const [selected, setSelected] = useState(0);
|
||||
return <>
|
||||
<Head title="Avaliação de fornecedores" />
|
||||
<div className="space-y-6 p-4 md:p-8">
|
||||
<PageTitle eyebrow="Fornecedores" title="Avaliação de fornecedores" description="Acompanhamento da prestação da entidade fornecedora durante ou após o fornecimento." />
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-950">
|
||||
Proposta funcional para validação da organização. As áreas abaixo descrevem o circuito previsto; o registo e a aprovação de avaliações ainda não estão disponíveis. Pesos, escala e limiares são ilustrativos.
|
||||
</div>
|
||||
<nav aria-label="Áreas da avaliação de fornecedores" className="flex flex-wrap gap-2">
|
||||
{areas.map(([label], index) => <button key={label} type="button" aria-pressed={selected === index} onClick={() => setSelected(index)} className={`rounded-lg border px-4 py-2 text-sm ${selected === index ? 'bg-primary text-primary-foreground' : 'bg-card hover:bg-muted'}`}>{label}</button>)}
|
||||
</nav>
|
||||
<Panel title={areas[selected][0]} description={areas[selected][1]}>
|
||||
<p className="text-sm text-muted-foreground">Área prevista na proposta. Não são apresentados resultados de fornecedores reais.</p>
|
||||
</Panel>
|
||||
<Panel title="Proposta funcional completa" description="Qualificação verifica elegibilidade; avaliação analisa o cumprimento efectivo. Uma pontuação não suspende nem exclui automaticamente um fornecedor.">
|
||||
<details><summary className="cursor-pointer font-medium">Consultar campos, grelhas, evidências e regras propostas</summary>
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-7">{proposal}</div>
|
||||
type Row = {
|
||||
id: number;
|
||||
revision: number;
|
||||
version: number;
|
||||
status: string;
|
||||
supplier: string;
|
||||
title: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
};
|
||||
export default function SupplierEvaluations({
|
||||
ready,
|
||||
records,
|
||||
filters,
|
||||
suppliers,
|
||||
orders,
|
||||
notice,
|
||||
}: {
|
||||
ready: boolean;
|
||||
records: {
|
||||
data: Row[];
|
||||
total: number;
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
} | null;
|
||||
filters: { supplier_id?: number; status?: string };
|
||||
suppliers: { id: number; name: string; nuit: string }[];
|
||||
orders: EvaluationOrder[];
|
||||
notice?: string;
|
||||
}) {
|
||||
const form = useForm({
|
||||
supplier_id: '',
|
||||
operation_key: crypto.randomUUID(),
|
||||
payload: emptyEvaluation(),
|
||||
});
|
||||
const filter = useForm({
|
||||
supplier_id: String(filters.supplier_id ?? ''),
|
||||
status: filters.status ?? '',
|
||||
});
|
||||
const supplierOrders = orders.filter(
|
||||
(order) => order.supplier_id === Number(form.data.supplier_id),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Head title="Avaliação de fornecedores" />
|
||||
<div className="mx-auto max-w-7xl space-y-6 px-5 py-8 lg:px-8">
|
||||
<PageTitle
|
||||
eyebrow="Fornecedores"
|
||||
title="Avaliação de fornecedores"
|
||||
description="Prepare a avaliação qualitativa, reúna evidências, submeta para revisão independente e consulte o histórico e o relatório de cada revisão."
|
||||
action={
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/relatorios/medidas-fornecedores">
|
||||
Acompanhar medidas de melhoria
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-teal-300 p-3 text-sm"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!ready && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded border border-amber-300 p-3"
|
||||
>
|
||||
O módulo está a ser instalado. O cadastro de
|
||||
fornecedores e os processos existentes permanecem
|
||||
disponíveis.
|
||||
</p>
|
||||
)}
|
||||
<Panel title="Avaliações e histórico por fornecedor">
|
||||
<form
|
||||
className="mb-5 flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
filter.get('/fornecedores/avaliacoes', {
|
||||
preserveState: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fornecedor
|
||||
<select
|
||||
className={controlClass}
|
||||
value={filter.data.supplier_id}
|
||||
onChange={(event) =>
|
||||
filter.setData(
|
||||
'supplier_id',
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Todos os autorizados</option>
|
||||
{suppliers.map((supplier) => (
|
||||
<option
|
||||
key={supplier.id}
|
||||
value={supplier.id}
|
||||
>
|
||||
{supplier.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Estado
|
||||
<select
|
||||
className={controlClass}
|
||||
value={filter.data.status}
|
||||
onChange={(event) =>
|
||||
filter.setData('status', event.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
{Object.entries(evaluationStates).map(
|
||||
([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={filter.processing}>
|
||||
Aplicar filtros
|
||||
</Button>
|
||||
<Link
|
||||
className="py-2 text-sm underline"
|
||||
href="/fornecedores/avaliacoes"
|
||||
>
|
||||
Limpar
|
||||
</Link>
|
||||
</form>
|
||||
{records && (
|
||||
<>
|
||||
<p className="mb-3 text-sm">
|
||||
{records.total} avaliações/revisões no âmbito
|
||||
autorizado.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-180 text-left text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2">Avaliação</th>
|
||||
<th className="p-2">
|
||||
Fornecedor / período
|
||||
</th>
|
||||
<th className="p-2">Estado</th>
|
||||
<th className="p-2">Consultar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.data.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-t"
|
||||
>
|
||||
<td className="p-2">
|
||||
AVF-{row.id} · Revisão{' '}
|
||||
{row.revision}
|
||||
<span className="block font-medium">
|
||||
{row.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{row.supplier}
|
||||
<span className="block text-muted-foreground">
|
||||
{row.period_start ??
|
||||
'Início por indicar'}{' '}
|
||||
—{' '}
|
||||
{row.period_end ??
|
||||
'Fim por indicar'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{evaluationStates[
|
||||
row.status
|
||||
] ?? row.status}
|
||||
</td>
|
||||
<td className="space-y-2 p-2">
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/fornecedores/avaliacoes/${row.id}`}
|
||||
>
|
||||
Abrir avaliação
|
||||
</Link>
|
||||
<Link
|
||||
className="block underline"
|
||||
href={`/fornecedores/avaliacoes/${row.id}/relatorio`}
|
||||
>
|
||||
Relatório
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{records.data.length === 0 && (
|
||||
<p className="py-4 text-sm">
|
||||
Sem avaliações nos filtros e no âmbito
|
||||
autorizado.
|
||||
</p>
|
||||
)}
|
||||
<nav
|
||||
aria-label="Paginação das avaliações"
|
||||
className="mt-4 flex flex-wrap justify-between gap-3 text-sm"
|
||||
>
|
||||
<span>
|
||||
Página {records.current_page} de{' '}
|
||||
{records.last_page}
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{records.prev_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={records.prev_page_url}
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{records.next_page_url && (
|
||||
<Link
|
||||
className="underline"
|
||||
href={records.next_page_url}
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel
|
||||
title="Preparar nova avaliação"
|
||||
description="Bens e serviços são avaliados separadamente. O relatório regista o âmbito escolhido e não calcula médias entre componentes."
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
form.post('/fornecedores/avaliacoes', {
|
||||
preserveState: 'errors',
|
||||
preserveScroll: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-1 text-sm">
|
||||
Fornecedor
|
||||
<select
|
||||
className={controlClass}
|
||||
disabled={!ready || form.processing}
|
||||
value={form.data.supplier_id}
|
||||
onChange={(event) =>
|
||||
form.setData({
|
||||
...form.data,
|
||||
supplier_id: event.target.value,
|
||||
payload: {
|
||||
...form.data.payload,
|
||||
order_ids: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Escolher fornecedor</option>
|
||||
{suppliers.map((supplier) => (
|
||||
<option
|
||||
key={supplier.id}
|
||||
value={supplier.id}
|
||||
>
|
||||
{supplier.name} · {supplier.nuit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<SupplierEvaluationFields
|
||||
payload={form.data.payload}
|
||||
orders={supplierOrders}
|
||||
disabled={!ready || form.processing}
|
||||
onChange={(payload) =>
|
||||
form.setData('payload', payload)
|
||||
}
|
||||
/>
|
||||
{Object.entries(form.errors).map(([key, error]) => (
|
||||
<p
|
||||
key={key}
|
||||
role="alert"
|
||||
className="text-sm text-red-700"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
<Button
|
||||
disabled={
|
||||
!ready ||
|
||||
form.processing ||
|
||||
!form.data.supplier_id
|
||||
}
|
||||
>
|
||||
Guardar rascunho
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
<details className="rounded-lg border p-4 text-sm">
|
||||
<summary className="cursor-pointer font-medium">
|
||||
Proposta de grelhas quantitativas e evoluções previstas
|
||||
</summary>
|
||||
<p className="my-3">
|
||||
O ciclo disponível é qualitativo. Pesos, limiares,
|
||||
pontuações, planos de melhoria acompanhados e
|
||||
contestação mantêm-se distintos desta entrega; os
|
||||
valores ilustrativos abaixo não foram activados como
|
||||
política.
|
||||
</p>
|
||||
<div className="whitespace-pre-wrap text-muted-foreground">
|
||||
{proposal}
|
||||
</div>
|
||||
</details>
|
||||
</Panel>
|
||||
</div>
|
||||
</>;
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
SupplierEvaluations.layout = { breadcrumbs: [{ title: 'Fornecedores', href: '/fornecedores' }, { title: 'Avaliação de fornecedores', href: '/fornecedores/avaliacoes' }] };
|
||||
SupplierEvaluations.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Fornecedores', href: '/fornecedores' },
|
||||
{
|
||||
title: 'Avaliação de fornecedores',
|
||||
href: '/fornecedores/avaliacoes',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ export type User = {
|
||||
| 'procurement'
|
||||
| 'finance'
|
||||
| 'warehouse'
|
||||
| 'admin';
|
||||
| 'admin'
|
||||
| 'configured';
|
||||
avatar?: string;
|
||||
email_verified_at: string | null;
|
||||
created_at: string;
|
||||
|
||||
@@ -68,6 +68,7 @@ export const roleLabels: Record<string, string> = {
|
||||
finance: 'Finanças',
|
||||
warehouse: 'Recepção / Armazém',
|
||||
admin: 'Administrador',
|
||||
configured: 'Perfil definido pela empresa',
|
||||
};
|
||||
export const statusLabels: Record<string, string> = {
|
||||
draft: 'Rascunho',
|
||||
|
||||
Reference in New Issue
Block a user