Receber documentos privados e locais de disponibilidade nas candidaturas
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 20:52:10 +02:00
parent 2134aa084b
commit bf84575d10
26 changed files with 2756 additions and 49 deletions
@@ -0,0 +1,34 @@
export type AvailabilityLocation = {
province: string;
district: string;
locality: string;
};
export function CandidateAvailability({
data,
}: {
data?: {
availability?: AvailabilityLocation;
additional_availability?: AvailabilityLocation[];
};
}) {
if (!data?.availability) return null;
return (
<section className="space-y-2">
<h3 className="font-semibold">
Locais de disponibilidade do perfil enviado
</h3>
<ul className="list-inside list-disc">
{[
data.availability,
...(data.additional_availability ?? []),
].map((location, index) => (
<li key={index}>
{location.province} · {location.district} ·{' '}
{location.locality}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,42 @@
export type ReceivedDocument = {
key: string;
name: string;
size: number;
sha256: string;
verification: string;
};
export function CandidateDocumentList({
documents,
baseUrl,
}: {
documents?: Record<string, ReceivedDocument> | [];
baseUrl: string;
}) {
if (!documents || Object.keys(documents).length === 0) return null;
return (
<section className="space-y-2">
<h3 className="font-semibold">Documentos recebidos</h3>
<ul className="space-y-2">
{Object.values(documents).map((document) => (
<li key={document.key}>
<a
className="underline"
href={`${baseUrl}/documentos/${document.key}`}
>
{document.name}
</a>{' '}
· {document.size} bytes
<p className="text-xs break-all">
SHA-256: {document.sha256}
</p>
</li>
))}
</ul>
<p className="text-sm">
Formato conferido na recepção. O conteúdo e a autenticidade
dependem da análise documental.
</p>
</section>
);
}
+114 -6
View File
@@ -24,7 +24,13 @@ export default function Opportunity({
version: number;
definition: {
fields: Field[];
documents: { key: string; label: string; required: boolean }[];
documents: {
key: string;
label: string;
required: boolean;
formats: string[];
max_kb: number;
}[];
};
};
};
@@ -35,11 +41,16 @@ export default function Opportunity({
phone?: string;
summary: string;
availability: Location;
additional_availability?: Location[];
};
};
email: string;
emailVerified: boolean;
}) {
const locations = [
profile.data.availability,
...(profile.data.additional_availability ?? []),
];
const source: Record<string, string | Location> = {
name: profile.data.name,
full_name: profile.data.name,
@@ -64,6 +75,7 @@ export default function Opportunity({
: '',
]),
) as Record<string, string | Location>,
documents: {} as Record<string, File>,
});
return (
<>
@@ -110,6 +122,80 @@ export default function Opportunity({
{field.label}
{field.required ? ' *' : ''}
</legend>
{field.key === 'availability_location' &&
locations.length > 1 && (
<label className="grid gap-2 sm:col-span-3">
Usar local guardado no perfil
<select
className={controlClass}
value={locations.findIndex(
(location) => {
const answer =
form.data.answers[
field.key
];
return (
typeof answer ===
'object' &&
(
[
'province',
'district',
'locality',
] as const
).every(
(part) =>
answer[
part
] ===
location[
part
],
)
);
},
)}
onChange={(e) => {
const location =
locations[
Number(
e.target.value,
)
];
if (location)
form.setData(
'answers',
{
...form.data
.answers,
[field.key]: {
...location,
},
},
);
}}
>
<option value={-1}>
Local preenchido nesta
candidatura
</option>
{locations.map(
(location, index) => (
<option
value={index}
key={index}
>
{location.province}{' '}
·{' '}
{location.district}{' '}
·{' '}
{location.locality}
</option>
),
)}
</select>
</label>
)}
{(
[
'province',
@@ -193,11 +279,33 @@ export default function Opportunity({
</label>
),
)}
{opportunity.form.definition.documents.length > 0 && (
<p>
O envio de anexos ainda está em desenvolvimento. Não
é possível entregar documentos neste formulário.
</p>
{opportunity.form.definition.documents.map((document) => (
<label key={document.key} className="grid gap-2">
{document.label}
{document.required ? ' *' : ''}
<span className="text-sm">
{document.formats.join(', ')} · até{' '}
{document.max_kb} KB
</span>
<input
type="file"
className={controlClass}
required={document.required}
accept={document.formats
.map((format) => `.${format}`)
.join(',')}
onChange={(e) => {
const next = { ...form.data.documents };
const file = e.target.files?.[0];
if (file) next[document.key] = file;
else delete next[document.key];
form.setData('documents', next);
}}
/>
</label>
))}
{form.progress && (
<p>Envio dos documentos: {form.progress.percentage}%</p>
)}
<Button
disabled={
+84 -1
View File
@@ -7,6 +7,7 @@ type Data = {
phone: string | null;
summary: string;
availability: { province: string; district: string; locality: string };
additional_availability?: Data['availability'][];
};
export default function Profile({
companyId,
@@ -31,6 +32,7 @@ export default function Profile({
version: profile.version,
...profile.data,
phone: profile.data.phone ?? '',
additional_availability: profile.data.additional_availability ?? [],
reason: '',
});
return (
@@ -75,7 +77,7 @@ export default function Profile({
? 'Email confirmado. A identidade ainda depende de verificação própria.'
: 'Email ainda não confirmado. A entrada comprova apenas o acesso à conta.'}{' '}
Pode candidatar-se às oportunidades publicadas. O envio de
anexos ainda está em desenvolvimento.
documentos segue os requisitos de cada oportunidade.
</p>
{!emailVerified && (
<Button
@@ -181,6 +183,87 @@ export default function Profile({
),
)}
</fieldset>
{form.data.additional_availability.map(
(location, index) => (
<fieldset
key={index}
className="grid gap-3 sm:grid-cols-3"
>
<legend>
Outro local de disponibilidade {index + 1}
</legend>
{(
[
'province',
'district',
'locality',
] as const
).map((key) => (
<label className="grid gap-2" key={key}>
{
{
province: 'Província',
district: 'Distrito',
locality: 'Localidade',
}[key]
}
<input
className={controlClass}
required
maxLength={150}
value={location[key]}
onChange={(e) =>
form.setData(
'additional_availability',
form.data.additional_availability.map(
(item, position) =>
position === index
? {
...item,
[key]: e
.target
.value,
}
: item,
),
)
}
/>
</label>
))}
<Button
type="button"
variant="outline"
onClick={() =>
form.setData(
'additional_availability',
form.data.additional_availability.filter(
(_, position) =>
position !== index,
),
)
}
>
Retirar este local
</Button>
</fieldset>
),
)}
<Button
type="button"
variant="outline"
disabled={
form.data.additional_availability.length >= 20
}
onClick={() =>
form.setData('additional_availability', [
...form.data.additional_availability,
{ province: '', district: '', locality: '' },
])
}
>
Adicionar local de disponibilidade
</Button>
<p className="text-sm">
Disponibilidade é distinta da morada. Actualizar o
perfil cria uma versão para usos futuros, preservando as
+24 -1
View File
@@ -1,4 +1,12 @@
import { Head, Link } from '@inertiajs/react';
import {
CandidateDocumentList,
type ReceivedDocument,
} from '@/components/candidate-documents';
import {
CandidateAvailability,
type AvailabilityLocation,
} from '@/components/candidate-availability';
export default function Receipt({
companyId,
@@ -8,16 +16,24 @@ export default function Receipt({
companyId: number;
decision?: null | { outcome: string; message: string; decided_at: string };
receipt: {
id?: number;
reference: string;
received_at: string;
snapshot: {
documents?: Record<string, ReceivedDocument> | [];
opportunity: {
title: string;
form?: {
definition: { fields: { key: string; label: string }[] };
};
};
profile: { version: number };
profile: {
version: number;
data?: {
availability?: AvailabilityLocation;
additional_availability?: AvailabilityLocation[];
};
};
answers: Record<
string,
string | number | null | Record<string, string>
@@ -87,6 +103,13 @@ export default function Receipt({
),
)}
</dl>
<CandidateAvailability data={receipt.snapshot.profile.data} />
{receipt.id && (
<CandidateDocumentList
documents={receipt.snapshot.documents}
baseUrl={`/candidaturas/${companyId}/submissoes/${receipt.id}`}
/>
)}
<Link
className="underline"
href={`/candidaturas/${companyId}/perfil`}
+24 -1
View File
@@ -1,4 +1,12 @@
import { Head, Link, useForm } from '@inertiajs/react';
import {
CandidateDocumentList,
type ReceivedDocument,
} from '@/components/candidate-documents';
import {
CandidateAvailability,
type AvailabilityLocation,
} from '@/components/candidate-availability';
import { FormErrors, PageTitle, controlClass } from '@/components/procurement';
import { Button } from '@/components/ui/button';
@@ -122,6 +130,7 @@ export default function Submissions({
reference: string;
received_at: string;
snapshot: {
documents?: Record<string, ReceivedDocument> | [];
opportunity: {
form: {
definition: {
@@ -129,7 +138,14 @@ export default function Submissions({
};
};
};
profile: { data: { name: string; summary: string } };
profile: {
data: {
name: string;
summary: string;
availability?: AvailabilityLocation;
additional_availability?: AvailabilityLocation[];
};
};
answers: Record<string, unknown>;
};
}[];
@@ -234,6 +250,13 @@ export default function Submissions({
: 'Recebida, por triar'}
</p>
<p>{row.snapshot.profile.data.summary}</p>
<CandidateDocumentList
documents={row.snapshot.documents}
baseUrl={`/pessoas/${process.id}/recebidas/${row.id}`}
/>
<CandidateAvailability
data={row.snapshot.profile.data}
/>
<dl>
{Object.entries(row.snapshot.answers).map(
([key, value]) => (