707 lines
28 KiB
TypeScript
707 lines
28 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import { PageTitle, Panel, FormErrors } from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { date } from '@/types/procurement';
|
|
|
|
type Option = { id: number; name: string };
|
|
type RequestOption = { id: number; number: string; department_id: number };
|
|
type Item = {
|
|
id: number;
|
|
title: string;
|
|
nature: string;
|
|
department: string;
|
|
department_id: number;
|
|
canLink: boolean;
|
|
responsible: string;
|
|
responsible_id: number;
|
|
version: number;
|
|
canRecord: boolean;
|
|
requisition: { id: number; number: string } | null;
|
|
};
|
|
type Milestone = {
|
|
id: number;
|
|
purchase_plan_item_id: number;
|
|
label: string;
|
|
planned_on: string;
|
|
actual_on: string | null;
|
|
version: number;
|
|
};
|
|
type Audit = {
|
|
id: number;
|
|
action: string;
|
|
created_at: string;
|
|
actor: string;
|
|
before: Record<string, string | number | null> | null;
|
|
after: {
|
|
reason?: string;
|
|
milestone?: { planned_on: string; actual_on: string | null };
|
|
};
|
|
};
|
|
type Props = {
|
|
items: {
|
|
data: Item[];
|
|
prev_page_url: string | null;
|
|
next_page_url: string | null;
|
|
};
|
|
milestones: Milestone[];
|
|
history: Audit[];
|
|
users: Option[];
|
|
departments: Option[];
|
|
requisitions: RequestOption[];
|
|
canManage: boolean;
|
|
today: string;
|
|
};
|
|
const natureLabels: Record<string, string> = {
|
|
goods: 'Bens',
|
|
works: 'Obras',
|
|
services: 'Serviços',
|
|
consulting: 'Consultoria',
|
|
};
|
|
const selectClass =
|
|
'block h-10 w-full rounded-md border bg-background px-3 text-sm';
|
|
|
|
function CreateItem({
|
|
users,
|
|
departments,
|
|
requisitions,
|
|
}: Pick<Props, 'users' | 'departments' | 'requisitions'>) {
|
|
const form = useForm({
|
|
title: '',
|
|
nature: '',
|
|
department_id: '',
|
|
responsible_id: '',
|
|
requisition_id: '',
|
|
});
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post('/planeamento', {
|
|
preserveScroll: true,
|
|
onSuccess: () => form.reset(),
|
|
});
|
|
}}
|
|
className="grid gap-4 sm:grid-cols-2"
|
|
>
|
|
<Label>
|
|
Título da compra
|
|
<Input
|
|
required
|
|
minLength={3}
|
|
maxLength={200}
|
|
value={form.data.title}
|
|
onChange={(event) =>
|
|
form.setData('title', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label>
|
|
Natureza
|
|
<select
|
|
required
|
|
className={selectClass}
|
|
value={form.data.nature}
|
|
onChange={(event) =>
|
|
form.setData('nature', event.target.value)
|
|
}
|
|
>
|
|
<option value="">Seleccionar</option>
|
|
{Object.entries(natureLabels).map(([key, label]) => (
|
|
<option key={key} value={key}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label>
|
|
Departamento
|
|
<select
|
|
required
|
|
className={selectClass}
|
|
value={form.data.department_id}
|
|
onChange={(event) => {
|
|
form.setData('department_id', event.target.value);
|
|
form.setData('requisition_id', '');
|
|
}}
|
|
>
|
|
<option value="">Seleccionar</option>
|
|
{departments.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{entry.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label>
|
|
Responsável
|
|
<select
|
|
required
|
|
className={selectClass}
|
|
value={form.data.responsible_id}
|
|
onChange={(event) =>
|
|
form.setData('responsible_id', event.target.value)
|
|
}
|
|
>
|
|
<option value="">Seleccionar</option>
|
|
{users.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{entry.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label className="sm:col-span-2">
|
|
Requisição existente (opcional)
|
|
<select
|
|
className={selectClass}
|
|
value={form.data.requisition_id}
|
|
onChange={(event) =>
|
|
form.setData('requisition_id', event.target.value)
|
|
}
|
|
>
|
|
<option value="">
|
|
Planear antes de criar a requisição
|
|
</option>
|
|
{requisitions
|
|
.filter(
|
|
(entry) =>
|
|
String(entry.department_id) ===
|
|
form.data.department_id,
|
|
)
|
|
.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{entry.number}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<FormErrors errors={form.errors} />
|
|
<Button disabled={form.processing}>Adicionar ao plano</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function LinkRequest({
|
|
item,
|
|
requisitions,
|
|
}: {
|
|
item: Item;
|
|
requisitions: RequestOption[];
|
|
}) {
|
|
const form = useForm({
|
|
requisition_id: '',
|
|
version: item.version,
|
|
reason: '',
|
|
});
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post(`/planeamento/${item.id}/requisicao`, {
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
className="mt-4 grid gap-3"
|
|
>
|
|
<Label>
|
|
Requisição
|
|
<select
|
|
required
|
|
className={selectClass}
|
|
value={form.data.requisition_id}
|
|
onChange={(event) =>
|
|
form.setData('requisition_id', event.target.value)
|
|
}
|
|
>
|
|
<option value="">Seleccionar</option>
|
|
{requisitions
|
|
.filter(
|
|
(entry) =>
|
|
entry.department_id === item.department_id,
|
|
)
|
|
.map((entry) => (
|
|
<option key={entry.id} value={entry.id}>
|
|
{entry.number}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label>
|
|
Fundamento da ligação
|
|
<Input
|
|
required
|
|
minLength={10}
|
|
maxLength={2000}
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<FormErrors errors={form.errors} />
|
|
<Button disabled={form.processing}>Ligar requisição</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function AddMilestone({ id }: { id: number }) {
|
|
const form = useForm({ label: '', planned_on: '' });
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post(`/planeamento/${id}/marcos`, {
|
|
preserveScroll: true,
|
|
onSuccess: () => form.reset(),
|
|
});
|
|
}}
|
|
className="mt-4 grid gap-3 sm:grid-cols-2"
|
|
>
|
|
<Label>
|
|
Marco
|
|
<Input
|
|
required
|
|
minLength={3}
|
|
maxLength={120}
|
|
placeholder="Ex.: concluir avaliação"
|
|
value={form.data.label}
|
|
onChange={(event) =>
|
|
form.setData('label', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label>
|
|
Data prevista
|
|
<Input
|
|
type="date"
|
|
required
|
|
value={form.data.planned_on}
|
|
onChange={(event) =>
|
|
form.setData('planned_on', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<FormErrors errors={form.errors} />
|
|
<Button variant="outline" disabled={form.processing}>
|
|
Adicionar marco
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function Assign({ item, users }: { item: Item; users: Option[] }) {
|
|
const form = useForm({
|
|
responsible_id: String(item.responsible_id),
|
|
version: item.version,
|
|
reason: '',
|
|
});
|
|
return (
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post(`/planeamento/${item.id}/responsavel`, {
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
className="mt-4 grid gap-3"
|
|
>
|
|
<Label>
|
|
Responsável
|
|
<select
|
|
required
|
|
className={selectClass}
|
|
value={form.data.responsible_id}
|
|
onChange={(event) =>
|
|
form.setData('responsible_id', event.target.value)
|
|
}
|
|
>
|
|
{users.map((user) => (
|
|
<option key={user.id} value={user.id}>
|
|
{user.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Label>
|
|
<Label>
|
|
Motivo da alteração
|
|
<Input
|
|
required
|
|
minLength={10}
|
|
maxLength={2000}
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<FormErrors errors={form.errors} />
|
|
<Button variant="outline" disabled={form.processing}>
|
|
Actualizar responsável
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function MilestoneRow({
|
|
milestone,
|
|
canManage,
|
|
canRecord,
|
|
today,
|
|
}: {
|
|
milestone: Milestone;
|
|
canManage: boolean;
|
|
canRecord: boolean;
|
|
today: string;
|
|
}) {
|
|
const form = useForm<{
|
|
version: number;
|
|
actual_on: string;
|
|
reason: string;
|
|
planned_on?: string;
|
|
}>({
|
|
version: milestone.version,
|
|
actual_on: milestone.actual_on ?? '',
|
|
reason: '',
|
|
...(canManage ? { planned_on: milestone.planned_on } : {}),
|
|
});
|
|
const delta = milestone.actual_on
|
|
? Math.round(
|
|
(Date.parse(`${milestone.actual_on}T00:00:00Z`) -
|
|
Date.parse(`${milestone.planned_on}T00:00:00Z`)) /
|
|
86400000,
|
|
)
|
|
: null;
|
|
const status =
|
|
delta !== null
|
|
? delta > 0
|
|
? `Realizado com ${delta} dias de atraso`
|
|
: 'Realizado dentro do prazo'
|
|
: milestone.planned_on < today
|
|
? 'Data prevista ultrapassada'
|
|
: milestone.planned_on === today
|
|
? 'Previsto para hoje'
|
|
: 'Previsto';
|
|
return (
|
|
<li className="py-4">
|
|
<div className="flex flex-wrap justify-between gap-2">
|
|
<h3 className="text-sm font-semibold">{milestone.label}</h3>
|
|
<span className="text-xs text-muted-foreground">{status}</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
Previsto: {date(milestone.planned_on)} · Realizado:{' '}
|
|
{milestone.actual_on
|
|
? date(milestone.actual_on)
|
|
: 'Não registado'}
|
|
</p>
|
|
{canRecord && (
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer text-sm underline">
|
|
Actualizar datas
|
|
</summary>
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.patch(`/planeamento/marcos/${milestone.id}`, {
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
className="mt-3 grid gap-3 sm:grid-cols-2"
|
|
>
|
|
{canManage && (
|
|
<Label>
|
|
Data prevista
|
|
<Input
|
|
type="date"
|
|
required
|
|
value={form.data.planned_on}
|
|
onChange={(event) =>
|
|
form.setData(
|
|
'planned_on',
|
|
event.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</Label>
|
|
)}
|
|
<Label>
|
|
Data realizada
|
|
<Input
|
|
type="date"
|
|
max={today}
|
|
value={form.data.actual_on}
|
|
onChange={(event) =>
|
|
form.setData(
|
|
'actual_on',
|
|
event.target.value,
|
|
)
|
|
}
|
|
/>
|
|
</Label>
|
|
<Label className="sm:col-span-2">
|
|
Fundamento ou referência de suporte
|
|
<Input
|
|
required
|
|
minLength={10}
|
|
maxLength={2000}
|
|
value={form.data.reason}
|
|
onChange={(event) =>
|
|
form.setData('reason', event.target.value)
|
|
}
|
|
/>
|
|
</Label>
|
|
<p className="text-xs text-muted-foreground sm:col-span-2">
|
|
Deixar a data realizada vazia remove essa indicação,
|
|
conservando o valor anterior no histórico.
|
|
</p>
|
|
<FormErrors errors={form.errors} />
|
|
<Button disabled={form.processing}>
|
|
Guardar alteração
|
|
</Button>
|
|
</form>
|
|
</details>
|
|
)}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
export default function Planning({
|
|
items,
|
|
milestones,
|
|
history,
|
|
users,
|
|
departments,
|
|
requisitions,
|
|
canManage,
|
|
today,
|
|
}: Props) {
|
|
return (
|
|
<>
|
|
<Head title="Planeamento de compras" />
|
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
eyebrow="Organização das compras"
|
|
title="Planeamento"
|
|
description="Acompanhe responsáveis e datas previstas e realizadas por compra."
|
|
/>
|
|
<p className="rounded-xl border bg-muted/30 p-4 text-sm text-muted-foreground">
|
|
Os marcos são definidos para cada compra. Registar uma data
|
|
realizada não aprova a compra, valida o orçamento, emite
|
|
documentos ou confirma entregas e pagamentos.
|
|
</p>
|
|
{canManage && (
|
|
<Panel
|
|
title="Adicionar compra ao plano"
|
|
description="Bens, obras, serviços e consultoria são classificações do plano; não activam modalidades de contratação."
|
|
>
|
|
<CreateItem
|
|
users={users}
|
|
departments={departments}
|
|
requisitions={requisitions}
|
|
/>
|
|
</Panel>
|
|
)}
|
|
{items.data.map((item) => (
|
|
<Panel
|
|
key={item.id}
|
|
title={item.title}
|
|
description={`${natureLabels[item.nature]} · ${item.department} · Responsável: ${item.responsible}`}
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
{item.requisition ? (
|
|
<Link
|
|
className="text-sm underline"
|
|
href={`/requisicoes/${item.requisition.id}/processo`}
|
|
>
|
|
Processo {item.requisition.number}
|
|
</Link>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem requisição visível associada
|
|
</p>
|
|
)}
|
|
{canManage && (
|
|
<details>
|
|
<summary className="cursor-pointer text-sm underline">
|
|
Alterar responsável
|
|
</summary>
|
|
<Assign
|
|
key={`${item.id}-${item.version}`}
|
|
item={item}
|
|
users={users}
|
|
/>
|
|
</details>
|
|
)}
|
|
</div>
|
|
{item.canLink && (
|
|
<details className="mt-3">
|
|
<summary className="cursor-pointer text-sm underline">
|
|
Ligar uma requisição criada posteriormente
|
|
</summary>
|
|
<LinkRequest
|
|
key={`${item.id}-${item.version}`}
|
|
item={item}
|
|
requisitions={requisitions}
|
|
/>
|
|
</details>
|
|
)}
|
|
<ul className="mt-3 divide-y">
|
|
{milestones
|
|
.filter(
|
|
(milestone) =>
|
|
milestone.purchase_plan_item_id ===
|
|
item.id,
|
|
)
|
|
.map((milestone) => (
|
|
<MilestoneRow
|
|
key={`${milestone.id}-${milestone.version}`}
|
|
milestone={milestone}
|
|
canManage={canManage}
|
|
canRecord={item.canRecord}
|
|
today={today}
|
|
/>
|
|
))}
|
|
</ul>
|
|
{!milestones.some(
|
|
(milestone) =>
|
|
milestone.purchase_plan_item_id === item.id,
|
|
) && (
|
|
<p className="py-4 text-sm text-muted-foreground">
|
|
Sem marcos definidos.
|
|
</p>
|
|
)}
|
|
{canManage && (
|
|
<details className="border-t pt-4">
|
|
<summary className="cursor-pointer text-sm font-medium">
|
|
Adicionar marco
|
|
</summary>
|
|
<AddMilestone id={item.id} />
|
|
</details>
|
|
)}
|
|
</Panel>
|
|
))}
|
|
{!items.data.length && (
|
|
<Panel title="Sem compras no plano">
|
|
<p className="text-sm text-muted-foreground">
|
|
Ainda não existem itens disponíveis para o seu
|
|
perfil.
|
|
</p>
|
|
</Panel>
|
|
)}
|
|
<nav
|
|
aria-label="Páginas do plano"
|
|
className="flex justify-between gap-3"
|
|
>
|
|
{items.prev_page_url ? (
|
|
<Button asChild variant="outline">
|
|
<Link href={items.prev_page_url}>Anterior</Link>
|
|
</Button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{items.next_page_url && (
|
|
<Button asChild variant="outline">
|
|
<Link href={items.next_page_url}>Seguinte</Link>
|
|
</Button>
|
|
)}
|
|
</nav>
|
|
{canManage && (
|
|
<Panel
|
|
title="Histórico das alterações"
|
|
description="Últimos 50 eventos de planeamento da empresa, com as datas anteriores e o fundamento."
|
|
>
|
|
{history.length ? (
|
|
<div className="space-y-3">
|
|
{history.map((event) => (
|
|
<details
|
|
key={event.id}
|
|
className="rounded-lg border p-3"
|
|
>
|
|
<summary className="cursor-pointer text-sm">
|
|
{event.action === 'planning.created'
|
|
? 'Compra adicionada'
|
|
: event.action ===
|
|
'planning.assigned'
|
|
? 'Responsável alterado'
|
|
: event.action ===
|
|
'planning.milestone_created'
|
|
? 'Marco criado'
|
|
: 'Datas actualizadas'}{' '}
|
|
· {date(event.created_at)}
|
|
</summary>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Por {event.actor}
|
|
</p>
|
|
<p className="mt-2 text-sm">
|
|
{typeof event.after.reason ===
|
|
'string'
|
|
? event.after.reason
|
|
: 'Registo inicial'}
|
|
</p>
|
|
{event.before && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Antes: previsto{' '}
|
|
{String(
|
|
event.before.planned_on ??
|
|
'—',
|
|
)}
|
|
, realizado{' '}
|
|
{String(
|
|
event.before.actual_on ??
|
|
'—',
|
|
)}
|
|
, responsável{' '}
|
|
{String(
|
|
event.before.responsible_id
|
|
? (users.find(
|
|
(user) =>
|
|
user.id ===
|
|
event.before
|
|
?.responsible_id,
|
|
)?.name ??
|
|
'Indisponível')
|
|
: '—',
|
|
)}
|
|
{event.after.milestone && (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Depois: previsto{' '}
|
|
{date(
|
|
event.after
|
|
.milestone
|
|
.planned_on,
|
|
)}
|
|
, realizado{' '}
|
|
{event.after.milestone
|
|
.actual_on
|
|
? date(
|
|
event.after
|
|
.milestone
|
|
.actual_on,
|
|
)
|
|
: 'Não registado'}
|
|
</p>
|
|
)}
|
|
</p>
|
|
)}
|
|
</details>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">
|
|
Sem alterações registadas.
|
|
</p>
|
|
)}
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
Planning.layout = {
|
|
breadcrumbs: [
|
|
{ title: 'Compras', href: '/requisicoes' },
|
|
{ title: 'Planeamento', href: '/planeamento' },
|
|
],
|
|
};
|