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

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' },
],
};