Implementa planeamento de compras com marcos e responsabilidades por empresa
This commit is contained in:
@@ -17,6 +17,7 @@ const groups: { label: string; entries: Entry[] }[] = [
|
||||
{
|
||||
label: 'Compras',
|
||||
entries: [
|
||||
{ label: 'Planeamento', path: '/planeamento', demo: '' },
|
||||
{
|
||||
label: 'Requisições',
|
||||
path: '/requisicoes',
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
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' },
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user