340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import {
|
|
readRows,
|
|
type ProjectMilestone,
|
|
type TeamNeed,
|
|
} from '@/lib/project-dossier';
|
|
|
|
const teamKeys = [
|
|
'competency',
|
|
'state',
|
|
'action',
|
|
'estimated_cost',
|
|
'due_date',
|
|
] as const;
|
|
const milestoneKeys = [
|
|
'title',
|
|
'due_date',
|
|
'status',
|
|
'planned_amount',
|
|
'actual_amount',
|
|
] as const;
|
|
|
|
function update<T extends Record<string, string>>(
|
|
rows: T[],
|
|
index: number,
|
|
key: keyof T,
|
|
value: string,
|
|
): T[] {
|
|
return rows.map((row, i) => (i === index ? { ...row, [key]: value } : row));
|
|
}
|
|
|
|
export function ProjectTeamPlanner({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const rows = readRows<TeamNeed>(value, teamKeys);
|
|
const persist = (next: TeamNeed[]) => onChange(JSON.stringify(next));
|
|
return (
|
|
<section
|
|
className="space-y-4"
|
|
aria-label="Necessidades de equipa e competências"
|
|
>
|
|
<p className="text-sm text-slate-700">
|
|
Registe competências declaradas, lacunas, resposta prevista,
|
|
custo e prazo. Não é uma certificação nem uma correspondência
|
|
automática a prestadores.
|
|
</p>
|
|
{rows.map((row, index) => (
|
|
<fieldset
|
|
key={index}
|
|
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
|
|
>
|
|
<legend className="font-medium">
|
|
Necessidade {index + 1}
|
|
</legend>
|
|
<label className="text-sm">
|
|
Competência ou função
|
|
<input
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.competency}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'competency',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Disponibilidade
|
|
<select
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.state}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'state',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<option value="unknown">Por avaliar</option>
|
|
<option value="available">
|
|
Disponível (declarado)
|
|
</option>
|
|
<option value="gap">Lacuna identificada</option>
|
|
</select>
|
|
</label>
|
|
<label className="text-sm">
|
|
Resposta planeada
|
|
<input
|
|
className="mt-1 w-full rounded border p-2"
|
|
placeholder="Formação, contratação, parceria…"
|
|
value={row.action}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'action',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Custo estimado (moeda do projecto)
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.estimated_cost}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'estimated_cost',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Prazo previsto
|
|
<input
|
|
type="date"
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.due_date}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'due_date',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="justify-self-start rounded border px-3 py-2 text-sm"
|
|
onClick={() =>
|
|
persist(rows.filter((_, i) => i !== index))
|
|
}
|
|
>
|
|
Remover necessidade {index + 1}
|
|
</button>
|
|
</fieldset>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="rounded border px-3 py-2"
|
|
onClick={() =>
|
|
persist([
|
|
...rows,
|
|
{
|
|
competency: '',
|
|
state: 'unknown',
|
|
action: '',
|
|
estimated_cost: '',
|
|
due_date: '',
|
|
},
|
|
])
|
|
}
|
|
>
|
|
Adicionar competência ou lacuna
|
|
</button>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function ProjectMilestonePlanner({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const rows = readRows<ProjectMilestone>(value, milestoneKeys);
|
|
const persist = (next: ProjectMilestone[]) =>
|
|
onChange(JSON.stringify(next));
|
|
return (
|
|
<section className="space-y-4" aria-label="Marcos de execução">
|
|
<p className="text-sm text-slate-700">
|
|
Compare datas e montantes planeados com o realizado. Estes
|
|
valores são declarados; não alteram automaticamente o plano
|
|
financeiro.
|
|
</p>
|
|
{rows.map((row, index) => (
|
|
<fieldset
|
|
key={index}
|
|
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
|
|
>
|
|
<legend className="font-medium">Marco {index + 1}</legend>
|
|
<label className="text-sm">
|
|
Marco ou resultado
|
|
<input
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.title}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'title',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Prazo previsto
|
|
<input
|
|
type="date"
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.due_date}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'due_date',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Estado
|
|
<select
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.status}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'status',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
>
|
|
<option value="planned">Planeado</option>
|
|
<option value="in_progress">Em curso</option>
|
|
<option value="completed">
|
|
Concluído (declarado)
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label className="text-sm">
|
|
Montante planeado
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.planned_amount}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'planned_amount',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="text-sm">
|
|
Montante realizado
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
className="mt-1 w-full rounded border p-2"
|
|
value={row.actual_amount}
|
|
onChange={(e) =>
|
|
persist(
|
|
update(
|
|
rows,
|
|
index,
|
|
'actual_amount',
|
|
e.target.value,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="justify-self-start rounded border px-3 py-2 text-sm"
|
|
onClick={() =>
|
|
persist(rows.filter((_, i) => i !== index))
|
|
}
|
|
>
|
|
Remover marco {index + 1}
|
|
</button>
|
|
</fieldset>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="rounded border px-3 py-2"
|
|
onClick={() =>
|
|
persist([
|
|
...rows,
|
|
{
|
|
title: '',
|
|
due_date: '',
|
|
status: 'planned',
|
|
planned_amount: '',
|
|
actual_amount: '',
|
|
},
|
|
])
|
|
}
|
|
>
|
|
Adicionar marco
|
|
</button>
|
|
</section>
|
|
);
|
|
}
|