Receber documentos privados e locais de disponibilidade nas candidaturas
tests / ci (push) Canceled after 0s
tests / ci (push) Canceled after 0s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
Reference in New Issue
Block a user