Files
compras/resources/js/pages/company/submission-forms.tsx
T
2026-10-30 20:14:24 +02:00

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>
</>
);
}