Files
Investidor-V2/resources/js/pages/education/index.tsx
T

898 lines
35 KiB
TypeScript

import { Link, router, useForm } from '@inertiajs/react';
import { useCallback, useEffect, useState } from 'react';
import {
portalRequest,
PortalApiError,
newSubmissionKey,
} from '@/lib/portal-api';
import EducationSubmittedSnapshot, {
type EducationDraft,
type EducationDraftSummary,
} from '@/components/education-submitted-snapshot';
import {
Management,
Field,
control,
button,
Pagination,
} from '../management/shared';
import type { Paginated } from '../management/shared';
import {
TerritoryFields,
type TerritoryOption,
} from '@/components/investment-territory';
type Option = { id: string; name?: string; title?: string; code?: string };
type RecordRow = {
id: string;
qualification: string;
quantity: number;
status: string;
version_no: number;
valid_until: string;
period_start: string;
period_end: string;
source: string;
can_review?: boolean;
review_unavailable?: 'self' | 'expired' | null;
details_url?: string;
};
type WorkCounts = {
total: number;
submitted: number;
reviewed: number;
rejected: number;
expired: number;
to_review: number;
};
type Props = {
workQueue?: {
actor_id: number;
drafts: number | null;
demands: WorkCounts;
capacities: WorkCounts;
};
draftsAvailable?: boolean;
selectedDraft?: EducationDraft | null;
ownDrafts?: Paginated<EducationDraftSummary> | null;
demands: Paginated<RecordRow>;
capacities: Paginated<RecordRow>;
skills: Option[];
occupations: Option[];
providers: Option[];
courses: Option[];
projects: Option[];
territories: TerritoryOption[];
today: string;
filters: {
q?: string;
status?: string;
geo_location_id?: string;
scope?: 'all' | 'mine' | 'review';
};
indicators: {
demand_records: number;
capacity_records: number;
expired_demands: number;
pending_demands: number;
};
};
const territoryLevels = ['province', 'district', 'admin_post', 'locality'];
function territoryPath(
id: string,
territories: TerritoryOption[],
): TerritoryOption[] {
const byId = new Map(
territories.map((territory) => [territory.id, territory]),
);
const path: TerritoryOption[] = [];
let current = byId.get(id);
while (current && path.length < territoryLevels.length) {
if (territoryLevels.includes(current.location_type))
path.unshift(current);
current = current.parent_id ? byId.get(current.parent_id) : undefined;
}
return path;
}
function Errors({ errors }: { errors: Record<string, string> }) {
return (
<>
{Object.entries(errors).map(([key, value]) => (
<p role="alert" key={key}>
{value}
</p>
))}
</>
);
}
function Select({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: Option[];
onChange: (value: string) => void;
}) {
return (
<Field label={label}>
<select
className={control}
value={value}
onChange={(e) => onChange(e.target.value)}
required
>
<option value="">Seleccione</option>
{value && !options.some((option) => option.id === value) && (
<option value={value}>
Selecção guardada não incluída nesta lista
</option>
)}
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.code ? `${o.code} — ` : ''}
{o.name ?? o.title}
</option>
))}
</select>
</Field>
);
}
function Catalog({
kind,
title,
providers,
}: {
kind: string;
title: string;
providers: Option[];
}) {
const form = useForm({ name: '', title: '', code: '', provider_id: '' });
return (
<details className="rounded border p-4">
<summary className="cursor-pointer font-semibold">{title}</summary>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.post(`/management/education/catalog/${kind}`, {
onSuccess: () => form.reset(),
});
}}
>
<Field label="Designação">
<input
className={control}
required
value={
kind === 'courses'
? form.data.title
: form.data.name
}
onChange={(e) =>
form.setData(
kind === 'courses' ? 'title' : 'name',
e.target.value,
)
}
/>
</Field>
{kind !== 'providers' && (
<Field label="Código">
<input
className={control}
required
value={form.data.code}
onChange={(e) =>
form.setData('code', e.target.value)
}
/>
</Field>
)}
{kind === 'courses' && (
<Select
label="Instituição"
value={form.data.provider_id}
options={providers}
onChange={(v) => form.setData('provider_id', v)}
/>
)}
<Errors errors={form.errors} />
<button className={button} disabled={form.processing}>
Criar cadastro interno
</button>
</form>
</details>
);
}
export function Intake({
kind,
props,
onSaved,
onDirty,
}: {
kind: 'demands' | 'capacities';
props: Props;
onSaved?: (draft: EducationDraft) => void;
onDirty?: (kind: 'demands' | 'capacities', dirty: boolean) => void;
}) {
const demand = kind === 'demands';
const initial =
props.selectedDraft?.kind === kind ? props.selectedDraft : null;
const [active, setActive] = useState<EducationDraft | null>(initial);
const [clientKey] = useState(newSubmissionKey);
const [draftBusy, setDraftBusy] = useState(false);
const [draftMessage, setDraftMessage] = useState('');
const [draftError, setDraftError] = useState('');
const [initialPath] = useState(() =>
territoryPath(
String(initial?.payload.geo_location_id ?? ''),
props.territories,
),
);
const [territoryResetKey, setTerritoryResetKey] = useState(0);
const form = useForm({
project_id: String(initial?.payload.project_id ?? ''),
course_id: String(initial?.payload.course_id ?? ''),
occupation_id: String(initial?.payload.occupation_id ?? ''),
skill_id: String(initial?.payload.skill_id ?? ''),
geo_location_id: String(initial?.payload.geo_location_id ?? ''),
employer: String(initial?.payload.employer ?? ''),
qualification: String(initial?.payload.qualification ?? ''),
quantity: String(initial?.payload.quantity ?? ''),
period_start: String(initial?.payload.period_start ?? ''),
period_end: String(initial?.payload.period_end ?? ''),
source: String(initial?.payload.source ?? ''),
reference_date: initial
? String(initial.payload.reference_date ?? '')
: props.today,
valid_until: String(initial?.payload.valid_until ?? ''),
});
useEffect(
() => onDirty?.(kind, form.isDirty),
[onDirty, kind, form.isDirty],
);
async function persist(submit = false) {
if (draftBusy) return;
setDraftBusy(true);
setDraftMessage('');
setDraftError('');
form.clearErrors();
const payload = { ...form.data };
try {
const url =
`/management/education/drafts/${kind}` +
(active ? `/${active.id}` : '') +
(submit ? '/submit' : '');
const saved = await portalRequest<EducationDraft>(url, {
method: submit || !active ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
version_no: active?.version_no ?? 0,
client_key: clientKey,
payload,
}),
});
setActive(saved);
form.setDefaults(payload);
onSaved?.(saved);
setDraftMessage(
submit
? 'Declaração submetida para revisão independente.'
: 'Rascunho privado guardado. Pode recuperá-lo na sua lista.',
);
} catch (failure) {
if (failure instanceof PortalApiError) {
form.setError(
Object.fromEntries(
Object.entries(failure.errors).map(
([key, messages]) => [
key,
messages[0] ?? failure.message,
],
),
),
);
setDraftError(
failure.status === 409
? 'O rascunho foi alterado noutra sessão. As suas edições continuam neste formulário; abra a versão guardada antes de tentar novamente.'
: failure.message,
);
} else
setDraftError(
'Não foi possível guardar. As suas edições continuam neste formulário.',
);
} finally {
setDraftBusy(false);
}
}
if (active?.state === 'submitted')
return <EducationSubmittedSnapshot draft={active} />;
return (
<details className="rounded border p-4" open={initial !== null}>
<summary className="cursor-pointer font-semibold">
{demand
? 'Declarar procura de trabalhadores'
: 'Declarar vagas de formação'}
</summary>
<form
className="mt-4 grid gap-4 md:grid-cols-2"
onSubmit={(e) => {
e.preventDefault();
if (active) {
void persist(true);
return;
}
form.post(`/management/education/${kind}`, {
onSuccess: () => {
form.reset();
setTerritoryResetKey((key) => key + 1);
},
});
}}
>
<fieldset
className="contents"
disabled={draftBusy || form.processing}
>
<Select
label={demand ? 'Projecto' : 'Curso'}
options={demand ? props.projects : props.courses}
value={
demand ? form.data.project_id : form.data.course_id
}
onChange={(v) =>
form.setData(demand ? 'project_id' : 'course_id', v)
}
/>
<Select
label="Ocupação"
options={props.occupations}
value={form.data.occupation_id}
onChange={(v) => form.setData('occupation_id', v)}
/>
<Select
label="Competência principal"
options={props.skills}
value={form.data.skill_id}
onChange={(v) => form.setData('skill_id', v)}
/>
<div className="md:col-span-2">
{initial?.payload.geo_location_id &&
form.data.geo_location_id ===
initial.payload.geo_location_id &&
initialPath.length === 0 && (
<p>
O local guardado já não consta do cadastro
disponível. A referência foi conservada;
escolha uma localização actual antes de
submeter.
</p>
)}
<TerritoryFields
key={territoryResetKey}
initial={initialPath}
disabled={draftBusy || form.processing}
endpoint="/management/territory-options"
onChange={(path) =>
form.setData(
'geo_location_id',
path[path.length - 1]?.id ?? '',
)
}
/>
</div>
{demand && (
<Field label="Empregador declarante">
<input
className={control}
required
value={form.data.employer}
onChange={(e) =>
form.setData('employer', e.target.value)
}
/>
</Field>
)}
<Field label="Qualificação exigida / desenvolvida">
<input
className={control}
required
value={form.data.qualification}
onChange={(e) =>
form.setData('qualification', e.target.value)
}
/>
</Field>
<Field
label={
demand
? 'Trabalhadores necessários'
: 'Vagas disponíveis (não trabalhadores)'
}
>
<input
className={control}
type="number"
min="1"
required
value={form.data.quantity}
onChange={(e) =>
form.setData('quantity', e.target.value)
}
/>
</Field>
{(
[
'period_start',
'period_end',
'reference_date',
'valid_until',
] as const
).map((key) => (
<Field
key={key}
label={
{
period_start: demand
? 'Início da necessidade'
: 'Início da formação',
period_end: demand
? 'Fim da necessidade'
: 'Conclusão prevista',
reference_date:
'Data de referência da fonte',
valid_until: 'Fonte válida até',
}[key]
}
>
<input
className={control}
type="date"
required
value={form.data[key]}
onChange={(e) =>
form.setData(key, e.target.value)
}
/>
</Field>
))}
<Field label="Fonte, referência da evidência e responsável pelos dados">
<textarea
className={control}
required
minLength={10}
value={form.data.source}
onChange={(e) =>
form.setData('source', e.target.value)
}
/>
</Field>
<div>
<Errors errors={form.errors} />
{draftError && <p role="alert">{draftError}</p>}
{draftMessage && <p role="status">{draftMessage}</p>}
{props.draftsAvailable && (
<>
<p className="text-sm">
Guardar rascunho conserva informação
incompleta apenas na sua conta; ainda não
declara trabalhadores ou vagas para análise.
</p>
<button
type="button"
className={button}
disabled={draftBusy || form.processing}
onClick={() => void persist()}
>
Guardar rascunho
</button>
{active && (
<p className="text-sm">
Rascunho guardado na versão{' '}
{active.version_no}. A submissão usa os
campos actuais deste formulário.
</p>
)}
</>
)}
<button className={button} disabled={form.processing}>
Submeter para revisão
</button>
</div>
</fieldset>
</form>
</details>
);
}
function Review({
row,
kind,
today,
}: {
row: RecordRow;
kind: string;
today: string;
}) {
return (
<article className="space-y-3 rounded border p-4">
<p className="font-semibold">
{row.qualification} · {row.quantity}{' '}
{kind === 'demands' ? 'trabalhadores' : 'vagas formativas'}
</p>
<p>
{row.period_start} a {row.period_end} ·{' '}
{row.valid_until < today
? 'Fonte expirada'
: (
{
submitted: 'Submetida',
reviewed: 'Revista internamente',
rejected: 'Rejeitada',
} as Record<string, string>
)[row.status]}
</p>
<p className="break-words">Fonte: {row.source}</p>
{row.review_unavailable === 'self' && (
<p>Submetida por si; a revisão cabe a outro gestor.</p>
)}
{row.details_url && (
<Link className="underline" href={row.details_url}>
{row.can_review
? 'Consultar e rever'
: 'Consultar declaração'}
</Link>
)}
</article>
);
}
export default function Index(props: Props) {
const [savedHere, setSavedHere] = useState<EducationDraftSummary[]>([]);
const [dirtyForms, setDirtyForms] = useState({
demands: false,
capacities: false,
});
const markDirty = useCallback(
(kind: 'demands' | 'capacities', dirty: boolean) => {
setDirtyForms((current) =>
current[kind] === dirty
? current
: { ...current, [kind]: dirty },
);
},
[],
);
const saved = useCallback((draft: EducationDraft) => {
setSavedHere((current) => [
{
id: draft.id,
kind: draft.kind,
version_no: draft.version_no,
state: draft.state,
updated_at: draft.updated_at,
label: String(draft.payload.qualification ?? ''),
},
...current.filter((item) => item.id !== draft.id),
]);
if (draft.state === 'submitted')
router.reload({
only: [
'demands',
'capacities',
'indicators',
'ownDrafts',
'workQueue',
],
});
}, []);
const unsaved = dirtyForms.demands || dirtyForms.capacities;
useEffect(() => {
const warn = (event: BeforeUnloadEvent) => {
if (unsaved) {
event.preventDefault();
event.returnValue = '';
}
};
window.addEventListener('beforeunload', warn);
const unsubscribe = router.on('before', (event) => {
if (
unsaved &&
event.detail.visit.method === 'get' &&
event.detail.visit.preserveState !== true &&
!window.confirm(
'Há alterações por guardar nos formulários de Educação. Pretende sair sem as guardar?',
)
)
event.preventDefault();
});
return () => {
window.removeEventListener('beforeunload', warn);
unsubscribe();
};
}, [unsaved]);
const ownDrafts = [
...savedHere,
...(props.ownDrafts?.data ?? []).filter(
(item) => !savedHere.some((saved) => saved.id === item.id),
),
];
const search = useForm({
scope: props.filters.scope ?? 'all',
q: props.filters.q ?? '',
status: props.filters.status ?? '',
geo_location_id: props.filters.geo_location_id ?? '',
});
return (
<Management title="Competências e investimento em educação">
<p>
Declarações internas de procura e oferta. A revisão não
constitui acreditação, certificação ou validação institucional.
Não introduza dados pessoais de formandos.
</p>
<section
aria-label="Indicadores internos"
className="grid gap-4 md:grid-cols-4"
>
<p>
Declarações de procura:{' '}
<strong>{props.indicators.demand_records}</strong>
</p>
<p>
Declarações de oferta:{' '}
<strong>{props.indicators.capacity_records}</strong>
</p>
<p>
Procuras a rever:{' '}
<strong>{props.indicators.pending_demands}</strong>
</p>
<p>
Procuras com fonte expirada:{' '}
<strong>{props.indicators.expired_demands}</strong>
</p>
</section>
<p>
Indicadores globais de registos internos, sem aplicação dos
filtros. Não representam totais de trabalhadores, lacunas
validadas ou resultados educativos.
</p>
{props.workQueue && (
<section
aria-label="O meu acompanhamento em Educação"
className="space-y-3 rounded border p-4"
>
<h2 className="text-lg font-semibold">
O meu acompanhamento
</h2>
<p>
Contagens das minhas declarações, em todos os filtros.
</p>
{props.workQueue.drafts !== null && (
<p>
Rascunhos privados por concluir:{' '}
{props.workQueue.drafts}
</p>
)}
<div className="grid gap-4 md:grid-cols-2">
{(['demands', 'capacities'] as const).map((kind) => {
const counts = props.workQueue?.[kind];
if (!counts) return null;
return (
<section key={kind} className="space-y-2">
<h3 className="font-semibold">
{kind === 'demands'
? 'Declarações de procura'
: 'Declarações de capacidade formativa'}
</h3>
<p>Submetidas por mim: {counts.total}</p>
<p>
Por rever, com fonte vigente:{' '}
{counts.submitted}
</p>
<p>
Revistas: {counts.reviewed} ·
Rejeitadas: {counts.rejected} · Fonte
expirada: {counts.expired}
</p>
<p>
Disponíveis para a minha revisão
independente: {counts.to_review}
</p>
</section>
);
})}
</div>
</section>
)}
<form
className="grid gap-4 md:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
search.get('/management/education', {
preserveState: true,
});
}}
>
<Field label="Âmbito das declarações">
<select
className={control}
value={search.data.scope}
onChange={(event) =>
search.setData({
...search.data,
scope: event.target.value as
| 'all'
| 'mine'
| 'review',
status:
event.target.value === 'review'
? ''
: search.data.status,
})
}
>
<option value="all">Todas as declarações</option>
<option value="mine">Submetidas por mim</option>
<option value="review">
Disponíveis para revisão independente
</option>
</select>
</Field>
<Field label="Pesquisar qualificação">
<input
className={control}
maxLength={150}
value={search.data.q}
onChange={(e) => search.setData('q', e.target.value)}
/>
</Field>
{search.data.scope !== 'review' && (
<Field label="Estado">
<select
className={control}
value={search.data.status}
onChange={(e) =>
search.setData('status', e.target.value)
}
>
<option value="">Todos</option>
<option value="submitted">
Por rever, fonte vigente
</option>
<option value="reviewed">
Revisto, fonte vigente
</option>
<option value="rejected">
Rejeitado, fonte vigente
</option>
<option value="expired">Fonte expirada</option>
</select>
</Field>
)}
<div className="md:col-span-2">
<TerritoryFields
initial={territoryPath(
search.data.geo_location_id,
props.territories,
)}
endpoint="/management/territory-options"
onChange={(path) =>
search.setData(
'geo_location_id',
path[path.length - 1]?.id ?? '',
)
}
/>
</div>
<div>
<button className={button} disabled={search.processing}>
Filtrar
</button>
<Link
className="ml-3 underline"
href="/management/education"
>
Limpar
</Link>
<Errors errors={search.errors} />
</div>
</form>
<section className="grid gap-3 md:grid-cols-2">
{[
['skills', 'Competências'],
['occupations', 'Ocupações'],
['providers', 'Instituições'],
['courses', 'Cursos'],
].map(([kind, title]) => (
<Catalog
key={kind}
kind={kind}
title={title}
providers={props.providers}
/>
))}
</section>
{props.draftsAvailable && (
<section className="space-y-3 rounded border p-4">
<h2 className="text-xl font-semibold">
Os meus rascunhos e submissões
</h2>
<p>
Estes registos pertencem apenas à sua conta. Rascunhos
incompletos não entram nos indicadores de trabalhadores
ou vagas.
</p>
<Link className="underline" href="/management/education">
Iniciar outra declaração
</Link>
{ownDrafts.length === 0 && (
<p>Ainda não guardou rascunhos.</p>
)}
<ul>
{ownDrafts.map((item) => (
<li key={item.id}>
<Link
className="underline"
href={`/management/education?draft=${encodeURIComponent(item.id)}`}
preserveState={false}
>
{item.kind === 'demands'
? 'Procura de trabalhadores'
: 'Capacidade formativa'}{' '}
—{' '}
{item.state === 'submitted'
? 'submetida'
: 'rascunho'}
, versão {item.version_no}
{item.label
? ` — ${item.label}`
: ''} —{' '}
{new Date(item.updated_at).toLocaleString(
'pt-MZ',
)}
</Link>
</li>
))}
</ul>
{props.ownDrafts && <Pagination page={props.ownDrafts} />}
</section>
)}
<Intake
key={`demands:${props.selectedDraft?.kind === 'demands' ? props.selectedDraft.id : 'new'}`}
kind="demands"
props={props}
onSaved={saved}
onDirty={markDirty}
/>
<Intake
key={`capacities:${props.selectedDraft?.kind === 'capacities' ? props.selectedDraft.id : 'new'}`}
kind="capacities"
props={props}
onSaved={saved}
onDirty={markDirty}
/>
{(['demands', 'capacities'] as const).map((kind) => (
<section key={kind} className="space-y-3">
<h2 className="text-xl font-semibold">
{kind === 'demands'
? 'Procura de mão-de-obra'
: 'Oferta formativa declarada'}
</h2>
{props[kind].data.map((row) => (
<Review
key={row.id}
row={row}
kind={kind}
today={props.today}
/>
))}
{!props[kind].data.length && <p>Ainda sem declarações.</p>}
<Pagination page={props[kind]} />
</section>
))}
</Management>
);
}