Files
Investidor-V2/resources/js/pages/education/index.tsx
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

570 lines
20 KiB
TypeScript

import { Link, useForm } from '@inertiajs/react';
import { useState } from 'react';
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;
};
type Props = {
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 };
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>
{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>
);
}
function Intake({
kind,
props,
}: {
kind: 'demands' | 'capacities';
props: Props;
}) {
const demand = kind === 'demands';
const [territoryResetKey, setTerritoryResetKey] = useState(0);
const form = useForm({
project_id: '',
course_id: '',
occupation_id: '',
skill_id: '',
geo_location_id: '',
employer: '',
qualification: '',
quantity: '',
period_start: '',
period_end: '',
source: '',
reference_date: props.today,
valid_until: '',
});
return (
<details className="rounded border p-4">
<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();
form.post(`/management/education/${kind}`, {
onSuccess: () => {
form.reset();
setTerritoryResetKey((key) => key + 1);
},
});
}}
>
<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">
<TerritoryFields
key={territoryResetKey}
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} />
<button className={button} disabled={form.processing}>
Submeter para revisão
</button>
</div>
</form>
</details>
);
}
function Review({
row,
kind,
today,
}: {
row: RecordRow;
kind: string;
today: string;
}) {
const form = useForm({
version_no: row.version_no,
status: 'reviewed',
review_reason: '',
});
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: 'Submetido',
reviewed: 'Revisto internamente',
rejected: 'Rejeitado',
} as Record<string, string>
)[row.status]}
</p>
<p className="break-words">Fonte: {row.source}</p>
{kind === 'demands' && (
<Link
className="underline"
href={`/management/education/demands/${row.id}/analysis`}
>
Analisar procura e oferta
</Link>
)}
<details>
<summary>Revisão independente</summary>
<form
className="mt-3 space-y-3"
onSubmit={(e) => {
e.preventDefault();
form.transform((data) => ({
...data,
version_no: row.version_no,
}));
form.post(
`/management/education/${kind}/${row.id}/review`,
);
}}
>
<Field label="Decisão interna">
<select
className={control}
value={form.data.status}
onChange={(e) =>
form.setData('status', e.target.value)
}
>
<option value="reviewed">Registar revisão</option>
<option value="rejected">
Rejeitar declaração
</option>
</select>
</Field>
<Field label="Justificação e evidência da revisão">
<textarea
className={control}
minLength={15}
required
value={form.data.review_reason}
onChange={(e) =>
form.setData('review_reason', e.target.value)
}
/>
</Field>
<Errors errors={form.errors} />
<button className={button} disabled={form.processing}>
Guardar decisão
</button>
</form>
</details>
</article>
);
}
export default function Index(props: Props) {
const search = useForm({
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>
<form
className="grid gap-4 md:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
search.get('/management/education', {
preserveState: true,
});
}}
>
<Field label="Pesquisar qualificação">
<input
className={control}
maxLength={150}
value={search.data.q}
onChange={(e) => search.setData('q', e.target.value)}
/>
</Field>
<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>
<Intake kind="demands" props={props} />
<Intake kind="capacities" props={props} />
{(['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>
);
}