281 lines
13 KiB
TypeScript
281 lines
13 KiB
TypeScript
import {
|
|
getProjectReadiness,
|
|
readPlannedRiskResponses,
|
|
serializePlannedRiskResponses,
|
|
type PlannedRiskResponse,
|
|
type ReadinessDraft,
|
|
} from '@/lib/project-readiness';
|
|
|
|
type Milestone = {
|
|
title: string;
|
|
due_date: string;
|
|
status: string;
|
|
planned_amount: string;
|
|
actual_amount: string;
|
|
};
|
|
|
|
function parseMilestones(value: string): Milestone[] {
|
|
try {
|
|
const parsed: unknown = JSON.parse(value || '[]');
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is Milestone =>
|
|
Boolean(
|
|
item &&
|
|
typeof item === 'object' &&
|
|
typeof item.title === 'string' &&
|
|
typeof item.due_date === 'string' &&
|
|
typeof item.planned_amount === 'string',
|
|
),
|
|
)
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const kindLabels = {
|
|
missing: 'Informação em falta',
|
|
unverified: 'Hipótese por verificar',
|
|
risk: 'Risco identificado',
|
|
};
|
|
|
|
export function ProjectReadiness({
|
|
draft,
|
|
onChange,
|
|
onNavigate,
|
|
}: {
|
|
draft: ReadinessDraft;
|
|
onChange: (key: string, value: string) => void;
|
|
onNavigate: (fieldKey: string) => void;
|
|
}) {
|
|
const items = getProjectReadiness(draft);
|
|
const responses = readPlannedRiskResponses(draft);
|
|
const milestones = parseMilestones(
|
|
typeof draft.project_execution_snapshot === 'string'
|
|
? draft.project_execution_snapshot
|
|
: '',
|
|
);
|
|
const currency =
|
|
typeof draft.currency === 'string' && draft.currency
|
|
? draft.currency
|
|
: 'moeda ainda não indicada';
|
|
|
|
function updateResponse(
|
|
riskId: string,
|
|
patch: Partial<PlannedRiskResponse>,
|
|
) {
|
|
const current = responses.find(
|
|
(response) => response.risk_id === riskId,
|
|
) ?? {
|
|
risk_id: riskId,
|
|
action: '',
|
|
owner: '',
|
|
estimated_cost: '',
|
|
due_date: '',
|
|
};
|
|
const next = responses.filter(
|
|
(response) => response.risk_id !== riskId,
|
|
);
|
|
next.push({ ...current, ...patch });
|
|
onChange(
|
|
'project_risk_responses_snapshot',
|
|
serializePlannedRiskResponses(next),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section
|
|
className="space-y-4"
|
|
aria-labelledby="project-readiness-title"
|
|
>
|
|
<div>
|
|
<h4
|
|
id="project-readiness-title"
|
|
className="text-lg font-semibold"
|
|
>
|
|
O que precisa de esclarecer
|
|
</h4>
|
|
<p className="mt-1 text-sm text-slate-700">
|
|
Os avisos abaixo resultam apenas dos campos actualmente
|
|
preenchidos. Não atribuem probabilidades, perdas ou uma
|
|
classificação de viabilidade.
|
|
</p>
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<p role="status" className="rounded border p-3 text-sm">
|
|
Não foram detectadas estas lacunas automáticas. Isto não
|
|
confirma a qualidade, exactidão ou viabilidade do projecto.
|
|
</p>
|
|
) : (
|
|
<ul className="space-y-3">
|
|
{items.map((item) => {
|
|
const response = responses.find(
|
|
(entry) => entry.risk_id === item.id,
|
|
) ?? {
|
|
risk_id: item.id,
|
|
action: '',
|
|
owner: '',
|
|
estimated_cost: '',
|
|
due_date: '',
|
|
};
|
|
return (
|
|
<li
|
|
key={item.id}
|
|
className="rounded-lg border border-slate-300 p-4"
|
|
>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<p className="text-xs font-semibold tracking-wide uppercase">
|
|
{kindLabels[item.kind]}
|
|
</p>
|
|
<h5 className="mt-1 font-semibold">
|
|
{item.title}
|
|
</h5>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="rounded border px-3 py-2 text-sm underline focus-visible:outline-2"
|
|
onClick={() =>
|
|
onNavigate(item.fieldKey)
|
|
}
|
|
>
|
|
Rever informação
|
|
</button>
|
|
</div>
|
|
<dl className="mt-3 grid gap-2 text-sm">
|
|
<div>
|
|
<dt className="font-medium">Origem</dt>
|
|
<dd>{item.origin}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-medium">
|
|
Parte afectada
|
|
</dt>
|
|
<dd>{item.affected}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-medium">
|
|
Possível consequência
|
|
</dt>
|
|
<dd>{item.consequence}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="font-medium">
|
|
Acção sugerida
|
|
</dt>
|
|
<dd>{item.nextAction}</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
<label className="grid gap-1 text-sm font-medium sm:col-span-2">
|
|
Resposta planeada
|
|
<textarea
|
|
className="min-h-20 rounded border p-2"
|
|
maxLength={500}
|
|
value={response.action}
|
|
onChange={(event) =>
|
|
updateResponse(item.id, {
|
|
action: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
{milestones.length > 0 && (
|
|
<label className="grid gap-1 text-sm font-medium sm:col-span-2">
|
|
Usar um marco já registado
|
|
<select
|
|
className="rounded border p-2"
|
|
defaultValue=""
|
|
onChange={(event) => {
|
|
const selected =
|
|
milestones[
|
|
Number(
|
|
event.target
|
|
.value,
|
|
)
|
|
];
|
|
if (selected)
|
|
updateResponse(
|
|
item.id,
|
|
{
|
|
action: selected.title,
|
|
due_date:
|
|
selected.due_date,
|
|
estimated_cost:
|
|
selected.planned_amount,
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
<option value="">
|
|
Escolher marco para
|
|
preencher resposta, prazo e
|
|
custo
|
|
</option>
|
|
{milestones.map(
|
|
(milestone, index) => (
|
|
<option
|
|
key={`${milestone.title}-${index}`}
|
|
value={index}
|
|
>
|
|
{milestone.title ||
|
|
`Marco ${index + 1}`}
|
|
</option>
|
|
),
|
|
)}
|
|
</select>
|
|
</label>
|
|
)}
|
|
<label className="grid gap-1 text-sm font-medium">
|
|
Responsável (se definido)
|
|
<input
|
|
className="rounded border p-2"
|
|
maxLength={160}
|
|
value={response.owner}
|
|
onChange={(event) =>
|
|
updateResponse(item.id, {
|
|
owner: event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm font-medium">
|
|
Custo previsto ({currency})
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
className="rounded border p-2"
|
|
value={response.estimated_cost}
|
|
onChange={(event) =>
|
|
updateResponse(item.id, {
|
|
estimated_cost:
|
|
event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="grid gap-1 text-sm font-medium">
|
|
Prazo previsto
|
|
<input
|
|
type="date"
|
|
className="rounded border p-2"
|
|
value={response.due_date}
|
|
onChange={(event) =>
|
|
updateResponse(item.id, {
|
|
due_date:
|
|
event.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|