484 lines
21 KiB
TypeScript
484 lines
21 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
|
import {
|
|
FormErrors,
|
|
PageTitle,
|
|
Panel,
|
|
controlClass,
|
|
} from '@/components/procurement';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
type Field = { key: string; label: string; type: string; required: boolean };
|
|
type Document = {
|
|
key: string;
|
|
label: string;
|
|
required: boolean;
|
|
formats: string[];
|
|
max_kb: number;
|
|
};
|
|
type Definition = {
|
|
available: boolean;
|
|
fields: Field[];
|
|
documents: Document[];
|
|
};
|
|
type Revision = {
|
|
id: number;
|
|
version: number;
|
|
created_by: number;
|
|
created_at: string;
|
|
reason: string;
|
|
definition: Definition;
|
|
};
|
|
export type SubmissionForm = {
|
|
audience: string;
|
|
modality: string;
|
|
version: number;
|
|
definition: Definition;
|
|
history: Revision[];
|
|
};
|
|
const audiences: Record<string, string> = {
|
|
staff: 'Recrutamento',
|
|
service: 'Prestadores individuais',
|
|
supplier: 'Fornecedores',
|
|
};
|
|
|
|
function Editor({
|
|
record,
|
|
canManage,
|
|
}: {
|
|
record: SubmissionForm;
|
|
canManage: boolean;
|
|
}) {
|
|
const form = useForm({
|
|
audience: record.audience,
|
|
modality: record.modality,
|
|
version: record.version,
|
|
definition: record.definition,
|
|
reason: '',
|
|
});
|
|
const updateField = (index: number, patch: Partial<Field>) =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
fields: form.data.definition.fields.map((field, i) =>
|
|
i === index ? { ...field, ...patch } : field,
|
|
),
|
|
});
|
|
const updateDocument = (index: number, patch: Partial<Document>) =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
documents: form.data.definition.documents.map((document, i) =>
|
|
i === index ? { ...document, ...patch } : document,
|
|
),
|
|
});
|
|
return (
|
|
<Panel
|
|
title={`${audiences[record.audience]} · ${record.modality === 'competition' ? 'Com concurso' : 'Espontânea'} · v${record.version}`}
|
|
>
|
|
<form
|
|
className="space-y-5"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
form.post('/administracao/candidaturas/formularios', {
|
|
preserveScroll: true,
|
|
});
|
|
}}
|
|
>
|
|
<FormErrors errors={form.errors} />
|
|
<fieldset
|
|
disabled={!canManage || form.processing}
|
|
className="space-y-5"
|
|
>
|
|
<label className="flex gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.data.definition.available}
|
|
onChange={(event) =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
available: event.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Modalidade prevista para recepção
|
|
</label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Esta configuração prepara os próximos formulários. Ainda
|
|
não abre candidaturas externas. Cada submissão
|
|
conservará a versão utilizada.
|
|
</p>
|
|
<h3 className="font-semibold">Campos do formulário</h3>
|
|
{form.data.definition.fields.map((field, index) => (
|
|
<fieldset
|
|
key={index}
|
|
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
|
|
>
|
|
<legend>Campo {index + 1}</legend>
|
|
<label>
|
|
Identificador estável
|
|
<input
|
|
className={controlClass}
|
|
required
|
|
pattern="[a-z][a-z0-9_]{0,49}"
|
|
value={field.key}
|
|
onChange={(e) =>
|
|
updateField(index, {
|
|
key: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Designação
|
|
<input
|
|
className={controlClass}
|
|
required
|
|
maxLength={200}
|
|
value={field.label}
|
|
onChange={(e) =>
|
|
updateField(index, {
|
|
label: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Tipo
|
|
<select
|
|
className={controlClass}
|
|
value={field.type}
|
|
onChange={(e) =>
|
|
updateField(index, {
|
|
type: e.target.value,
|
|
})
|
|
}
|
|
>
|
|
{Object.entries({
|
|
text: 'Texto',
|
|
textarea: 'Texto longo',
|
|
email: 'Email',
|
|
date: 'Data',
|
|
number: 'Número',
|
|
location:
|
|
'Disponibilidade: província, distrito e localidade',
|
|
}).map(([value, label]) => (
|
|
<option key={value} value={value}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{field.type === 'location' && (
|
|
<fieldset
|
|
disabled
|
|
className="grid gap-2 sm:col-span-2 sm:grid-cols-3"
|
|
>
|
|
<legend>
|
|
Pré-visualização da disponibilidade
|
|
</legend>
|
|
{[
|
|
'Província',
|
|
'Distrito',
|
|
'Localidade',
|
|
].map((label) => (
|
|
<label key={label}>
|
|
{label}
|
|
<input
|
|
className={controlClass}
|
|
placeholder={label}
|
|
/>
|
|
</label>
|
|
))}
|
|
</fieldset>
|
|
)}
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={field.required}
|
|
onChange={(e) =>
|
|
updateField(index, {
|
|
required: e.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Obrigatório
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
fields: form.data.definition.fields.filter(
|
|
(_, i) => i !== index,
|
|
),
|
|
})
|
|
}
|
|
>
|
|
Retirar campo desta versão
|
|
</Button>
|
|
</fieldset>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={form.data.definition.fields.length >= 40}
|
|
onClick={() =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
fields: [
|
|
...form.data.definition.fields,
|
|
{
|
|
key: '',
|
|
label: '',
|
|
type: 'text',
|
|
required: false,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
Adicionar campo
|
|
</Button>
|
|
<p className="text-sm text-muted-foreground">
|
|
Disponibilidade geográfica usa três campos próprios:
|
|
província, distrito e localidade. É o local onde a
|
|
pessoa pode trabalhar ou prestar serviço, distinto da
|
|
morada. A obrigatoriedade é definida neste formulário.
|
|
</p>
|
|
<h3 className="font-semibold">Documentos pedidos</h3>
|
|
{form.data.definition.documents.map((document, index) => (
|
|
<fieldset
|
|
key={index}
|
|
className="grid gap-3 rounded border p-3 sm:grid-cols-2"
|
|
>
|
|
<legend>Documento {index + 1}</legend>
|
|
<label>
|
|
Identificador estável
|
|
<input
|
|
className={controlClass}
|
|
required
|
|
pattern="[a-z][a-z0-9_]{0,49}"
|
|
value={document.key}
|
|
onChange={(e) =>
|
|
updateDocument(index, {
|
|
key: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Designação
|
|
<input
|
|
className={controlClass}
|
|
required
|
|
maxLength={200}
|
|
value={document.label}
|
|
onChange={(e) =>
|
|
updateDocument(index, {
|
|
label: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label>
|
|
Limite por ficheiro (KB)
|
|
<input
|
|
className={controlClass}
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={51200}
|
|
value={document.max_kb || ''}
|
|
onChange={(e) =>
|
|
updateDocument(index, {
|
|
max_kb: Number(e.target.value),
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={document.required}
|
|
onChange={(e) =>
|
|
updateDocument(index, {
|
|
required: e.target.checked,
|
|
})
|
|
}
|
|
/>
|
|
Obrigatório
|
|
</label>
|
|
<div className="flex flex-wrap gap-4">
|
|
{['pdf', 'docx', 'jpg', 'png'].map((format) => (
|
|
<label key={format}>
|
|
<input
|
|
type="checkbox"
|
|
checked={document.formats.includes(
|
|
format,
|
|
)}
|
|
onChange={(e) =>
|
|
updateDocument(index, {
|
|
formats: e.target.checked
|
|
? [
|
|
...document.formats,
|
|
format,
|
|
]
|
|
: document.formats.filter(
|
|
(v) =>
|
|
v !== format,
|
|
),
|
|
})
|
|
}
|
|
/>{' '}
|
|
{format.toUpperCase()}
|
|
</label>
|
|
))}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
documents:
|
|
form.data.definition.documents.filter(
|
|
(_, i) => i !== index,
|
|
),
|
|
})
|
|
}
|
|
>
|
|
Retirar documento desta versão
|
|
</Button>
|
|
</fieldset>
|
|
))}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={form.data.definition.documents.length >= 20}
|
|
onClick={() =>
|
|
form.setData('definition', {
|
|
...form.data.definition,
|
|
documents: [
|
|
...form.data.definition.documents,
|
|
{
|
|
key: '',
|
|
label: '',
|
|
required: false,
|
|
formats: [],
|
|
max_kb: 0,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
Adicionar documento
|
|
</Button>
|
|
<label className="grid gap-2">
|
|
Motivo da alteração
|
|
<textarea
|
|
className={controlClass}
|
|
required
|
|
minLength={10}
|
|
maxLength={4000}
|
|
value={form.data.reason}
|
|
onChange={(e) =>
|
|
form.setData('reason', e.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
{canManage && <Button>Guardar nova versão</Button>}
|
|
</fieldset>
|
|
</form>
|
|
<details className="mt-5">
|
|
<summary>
|
|
Histórico e definições anteriores ({record.history.length})
|
|
</summary>
|
|
{record.history.map((revision) => (
|
|
<div key={revision.id} className="my-3 border-t py-3">
|
|
<p>
|
|
Versão {revision.version} · {revision.created_at} ·
|
|
utilizador {revision.created_by}
|
|
</p>
|
|
<p>{revision.reason}</p>
|
|
<p>
|
|
{revision.definition.available
|
|
? 'Prevista para recepção'
|
|
: 'Indisponível para novas submissões'}
|
|
</p>
|
|
<ul>
|
|
{revision.definition.fields.map((field) => (
|
|
<li key={field.key}>
|
|
{field.label} · {field.type} ·{' '}
|
|
{field.required
|
|
? 'Obrigatório'
|
|
: 'Opcional'}
|
|
</li>
|
|
))}
|
|
{revision.definition.documents.map((document) => (
|
|
<li key={document.key}>
|
|
{document.label} ·{' '}
|
|
{document.formats.join(', ')} ·{' '}
|
|
{document.max_kb} KB ·{' '}
|
|
{document.required
|
|
? 'Obrigatório'
|
|
: 'Opcional'}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</details>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
export default function SubmissionForms({
|
|
forms,
|
|
canManage,
|
|
ready,
|
|
}: {
|
|
forms: SubmissionForm[];
|
|
canManage: boolean;
|
|
ready: boolean;
|
|
externalPortalAvailable: boolean;
|
|
}) {
|
|
return (
|
|
<>
|
|
<Head title="Formulários de candidaturas" />
|
|
<Link
|
|
className="m-5 inline-block underline"
|
|
href="/administracao/candidaturas/portal"
|
|
>
|
|
Configurar área externa do candidato
|
|
</Link>
|
|
<div className="mx-auto max-w-5xl space-y-6 p-5 lg:p-8">
|
|
<PageTitle
|
|
title="Inscrições e candidaturas electrónicas"
|
|
eyebrow="Administração"
|
|
description="Configure separadamente recrutamento, prestadores individuais e fornecedores, com concurso ou por inscrição espontânea."
|
|
/>
|
|
<Link
|
|
className="underline"
|
|
href="/administracao/parametrizacao"
|
|
>
|
|
Parametrização da aplicação
|
|
</Link>
|
|
<p>
|
|
Portal externo em desenvolvimento. Guardar estas definições
|
|
não publica oportunidades nem altera processos existentes.
|
|
</p>
|
|
{!ready ? (
|
|
<p>Instalação dos formulários pendente.</p>
|
|
) : (
|
|
forms.map((record) => (
|
|
<Editor
|
|
key={`${record.audience}:${record.modality}:${record.version}`}
|
|
record={record}
|
|
canManage={canManage}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|