570 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|