Mostrar oportunidades publicadas antes do registo de candidatos
This commit is contained in:
@@ -48,9 +48,11 @@ class CandidatePortalController extends Controller
|
||||
$portal = $profiles->portal($company);
|
||||
abort_unless($portal !== null, 404);
|
||||
$tenant = Tenant::query()->findOrFail($company);
|
||||
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']);
|
||||
|
||||
return $this->page($request, 'candidate/entry', ['company' => ['id' => $company, 'name' => $tenant->name],
|
||||
'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice]]);
|
||||
'portal' => ['version' => $portal->version, 'registration_open' => (bool) $portal->registration_open, 'notice' => $portal->notice],
|
||||
'opportunities' => app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))]);
|
||||
}
|
||||
|
||||
public function register(Request $request, int $company, CandidateProfiles $profiles): Response
|
||||
@@ -90,13 +92,12 @@ class CandidatePortalController extends Controller
|
||||
return redirect('/candidaturas/'.$company);
|
||||
}
|
||||
$profiles->event($account, 'profile.consulted', []);
|
||||
$filter = $request->validate(['opportunities_page' => 'sometimes|integer|min:1']);
|
||||
$opportunities = app(CandidateSubmissions::class)->publicOpportunities($company, (int) ($filter['opportunities_page'] ?? 1))->withPath('/candidaturas/'.$company.'/perfil');
|
||||
|
||||
return $this->page($request, 'candidate/profile', ['companyId' => $company, 'email' => $account->email, 'profile' => $profiles->profile($account),
|
||||
'emailVerified' => $account->email_verified_at !== null, 'accessStatus' => $request->session()->get('candidate_access_status'), 'emailDeliveryAvailable' => app(CandidateAccess::class)->deliveryAvailable(),
|
||||
'opportunities' => Schema::hasTable('candidate_opportunity_publications') ? DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id')
|
||||
->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true)->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString())
|
||||
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer')->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.version', '>', 'p.version'))
|
||||
->orderBy('r.closes_on')->limit(20)->get(['r.id', 'r.title', 'r.closes_on']) : [],
|
||||
'opportunities' => $opportunities->items(), 'opportunityPagination' => ['prev' => $opportunities->previousPageUrl(), 'next' => $opportunities->nextPageUrl()],
|
||||
'submissions' => Schema::hasTable('candidate_submissions') ? DB::table('candidate_submissions')->where('tenant_id', $company)->where('candidate_account_id', $account->id)->latest('id')->limit(20)->get(['id', 'reference', 'created_at']) : []]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\CandidateAccount;
|
||||
use App\Models\PeopleProcess;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -15,6 +16,25 @@ use stdClass;
|
||||
|
||||
class CandidateSubmissions
|
||||
{
|
||||
/** Only explicitly published, current and open opportunities; no internal process fields.
|
||||
* @return LengthAwarePaginator<int, stdClass>
|
||||
*/
|
||||
public function publicOpportunities(int $company, int $page = 1): LengthAwarePaginator
|
||||
{
|
||||
$path = '/candidaturas/'.$company;
|
||||
if (! Schema::hasTable('candidate_opportunity_publications')) {
|
||||
return (new LengthAwarePaginator([], 0, 12, $page))->withPath($path)->setPageName('opportunities_page');
|
||||
}
|
||||
|
||||
return DB::table('candidate_opportunity_publications as p')->join('people_processes as r', 'r.id', '=', 'p.people_process_id')
|
||||
->where('p.tenant_id', $company)->where('r.tenant_id', $company)->where('p.active', true)
|
||||
->where('r.status', 'open')->where('r.closes_on', '>=', today()->toDateString())
|
||||
->whereNotExists(fn ($q) => $q->selectRaw('1')->from('candidate_opportunity_publications as newer')
|
||||
->whereColumn('newer.people_process_id', 'p.people_process_id')->whereColumn('newer.tenant_id', 'p.tenant_id')->whereColumn('newer.version', '>', 'p.version'))
|
||||
->orderBy('r.closes_on')->orderBy('r.id')
|
||||
->paginate(12, ['r.id', 'r.title', 'r.kind', 'r.closes_on', 'p.description'], 'opportunities_page', $page)->withPath($path);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $input */
|
||||
public function publish(User $actor, int $processId, array $input): void
|
||||
{
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Consulta de oportunidades antes do registo, 30 de Outubro de 2026
|
||||
|
||||
A entrada da área do candidato mostra oportunidades explicitamente publicadas, abertas e dentro do prazo, com título, tipo, data e descrição pública. Não expõe finalidade interna, regras, candidaturas, avaliações ou documentos. A consulta é paginada por empresa; a última versão da publicação controla a visibilidade, incluindo retirada. O fecho de novos registos mantém a consulta pública e a entrada das contas existentes. Preparar e enviar candidaturas continuam a exigir autenticação própria, perfil e confirmação de email conforme as regras já existentes.
|
||||
|
||||
A área autenticada reutiliza a mesma consulta e passa a ter paginação, em vez de omitir oportunidades depois das primeiras 20. Retirada a mensagem desactualizada de que o envio de documentos ainda não estava disponível. Não foram abertas inscrições, publicadas oportunidades ou criadas contas para testar no domínio.
|
||||
|
||||
Verificação dirigida: 24 testes PHP/374 asserções em perfil e submissões; PostgreSQL descartável: 64 testes/1401 asserções, incluindo Pessoas e formatos de documentos. Dez testes frontend dirigidos, tipos, PHPStan sem erros, Pint e lint dirigidos aprovados. As provas integrais aceites da entrega bf84575 são reutilizadas, sem alegar nova execução de toda a suíte. Conferência visual tentada novamente: conector devolveu native pipe indisponível e browser host indisponível. Este incremento continua local e não faz parte da release bf84575 activa no domínio. Provas `proofs/candidate-public-opportunities.json` e `candidate-public-opportunities-pg.json`.
|
||||
|
||||
## Entrega em produção confirmada — bf84575, 30 de Outubro de 2026
|
||||
|
||||
O commit `bf84575d10d9063c2224945e6d5627da826148a9` está em main e activo no domínio, na release `20261030-190744-bf84575d10d9-KyKKDI71`. O serviço existente terminou com Result=success e ExecMainStatus=0. Esta confirmação actualiza as indicações históricas de instalação apenas local abaixo.
|
||||
|
||||
O instalador foi corrigido para exigir HTTP 200 em `/dashboard`: o anterior teste da raiz recebia o redireccionamento público esperado e provocava rollback. A validação e o rollback do instalador foram mantidos. Configuração anterior salvaguardada antes da correcção.
|
||||
|
||||
Delta reconciliado: 14 migrações, de 000028 a 000041, sem repetir as 30 já registadas. Backup fresco da base e armazenamento, restauro conferido e ensaio das migrações na cópia antes da transacção de produção. As 81 tabelas anteriores conservaram os seus dados nas colunas de origem; 18 novas tabelas conferidas vazias e schema final igual ao ensaio. Existem agora 44 migrações registadas. A migração 000028 acrescenta o parâmetro de IVA por empresa com valor inicial 16%, sem reescrever os valores e snapshots anteriores. Não foram criadas contas, abertas inscrições ou publicadas oportunidades automaticamente.
|
||||
|
||||
Armazenamento privado em storage partilhado persistente, com escrita conferida para www-data. Limites específicos do domínio: ficheiro 50M, pedido PHP e Nginx 104M, até 20 ficheiros; configuração Nginx validada e recarregada. Os 2M/8M ainda apresentados pelo PHP CLI são os limites do terminal e não os parâmetros específicos do domínio. PHP 8.4 dispõe de DOM/ZIP/fileinfo; PHP 8.5 CLI continua sem DOM/ZIP e não foi usado na instalação.
|
||||
|
||||
Conferência HTTP: 21 ecrãs públicos com componentes esperados, utilizador nulo e âmbito vazio; sete percursos privados protegidos. Prova `proofs/candidate-documents-production.json`. Conferência visual dirigida e ensaio com candidatura real no domínio continuam pendentes; não foram enviados ficheiros pessoais ou emails reais para os simular.
|
||||
|
||||
## CV e documentos na candidatura electrónica, 30 de Outubro de 2026
|
||||
|
||||
Recepção de documentos integrada no mesmo envio da candidatura. A obrigatoriedade, formatos (PDF, DOCX, JPG e PNG) e limite por ficheiro vêm do formulário preservado na preparação do processo, sem procurar a versão actual da empresa. Recusados documentos desconhecidos, obrigatórios em falta, tamanho excessivo e divergência de extensão/MIME/conteúdo. DOCX exige estrutura Word/XML válida, sem declarações de entidades/DOCTYPE, macros ou executáveis conhecidos, com limites de entradas e expansão do arquivo. A conferência de formato não constitui antivírus, prova de autenticidade, validação do mérito ou aprovação documental.
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:09:50.960374+00:00",
|
||||
"commit": "bf84575d10d9063c2224945e6d5627da826148a9",
|
||||
"release": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71",
|
||||
"deployment_service": "success",
|
||||
"migration_count": 44,
|
||||
"migrations_applied": 14,
|
||||
"existing_tables_preserved": 81,
|
||||
"new_tables_verified_empty": 18,
|
||||
"backup": "/root/compras-release-backups/20261030T190740921286Z-bf84575",
|
||||
"database_restore_verified": true,
|
||||
"storage_restore_verified": true,
|
||||
"private_storage_shared": true,
|
||||
"web_user_can_write_private_uploads": true,
|
||||
"nginx_validation_ok": true,
|
||||
"site_upload_configuration": [
|
||||
"\n client_max_body_size 104M;",
|
||||
" fastcgi_param PHP_VALUE \"upload_max_filesize=50M\\npost_max_size=104M\\nmax_file_uploads=20\";"
|
||||
],
|
||||
"cli_limits_apply_to_cli_only": true,
|
||||
"application": {
|
||||
"database_driver": "pgsql",
|
||||
"migration_count": 44,
|
||||
"candidate_migrations": [
|
||||
"2026_10_30_000037_create_candidate_profiles",
|
||||
"2026_10_30_000038_create_candidate_access_tokens",
|
||||
"2026_10_30_000039_create_candidate_submissions",
|
||||
"2026_10_30_000040_create_candidate_triage",
|
||||
"2026_10_30_000041_create_candidate_submission_documents"
|
||||
],
|
||||
"documents_table_exists": true,
|
||||
"document_count": 0,
|
||||
"public_navigation_enabled": true,
|
||||
"candidate_disk_root": "/var/www/compras-deploy/releases/20261030-190744-bf84575d10d9-KyKKDI71/storage/app/candidate-documents"
|
||||
},
|
||||
"http": {
|
||||
"checked_at": "2026-10-30T19:08:35.710090+00:00",
|
||||
"results": [
|
||||
{
|
||||
"path": "/",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/dashboard"
|
||||
},
|
||||
{
|
||||
"path": "/dashboard",
|
||||
"status": 200,
|
||||
"component": "dashboard",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/requisicoes",
|
||||
"status": 200,
|
||||
"component": "requisitions/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/requisicoes/criar",
|
||||
"status": 200,
|
||||
"component": "requisitions/form",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/fornecedores",
|
||||
"status": 200,
|
||||
"component": "suppliers/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/cotacoes",
|
||||
"status": 200,
|
||||
"component": "quotations/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/ordens",
|
||||
"status": 200,
|
||||
"component": "purchases/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/recepcoes",
|
||||
"status": 200,
|
||||
"component": "receipts/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/stock",
|
||||
"status": 200,
|
||||
"component": "stock/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/stock/catalogo",
|
||||
"status": 200,
|
||||
"component": "stock/catalogue",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/stock/operacoes",
|
||||
"status": 200,
|
||||
"component": "stock/operations",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/equipamentos",
|
||||
"status": 200,
|
||||
"component": "equipment/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/planeamento",
|
||||
"status": 200,
|
||||
"component": "planning/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas",
|
||||
"status": 200,
|
||||
"component": "people/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/orcamentos",
|
||||
"status": 200,
|
||||
"component": "budgets/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/facturas",
|
||||
"status": 200,
|
||||
"component": "invoices/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/pagamentos",
|
||||
"status": 200,
|
||||
"component": "payments/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/regras-investimento",
|
||||
"status": 200,
|
||||
"component": "investment/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/politica-admissao",
|
||||
"status": 200,
|
||||
"component": "purchases/admission-policy",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/politica-recomendacao",
|
||||
"status": 200,
|
||||
"component": "purchases/recommendation-policy",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/revisao-compras",
|
||||
"status": 200,
|
||||
"component": "purchase-controls/index",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/settings/appearance",
|
||||
"status": 200,
|
||||
"component": "settings/appearance",
|
||||
"anonymous": true,
|
||||
"data_scope": "none",
|
||||
"version": "d57b6033713dbe71fe3664abe9f56f4a"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/cadastro",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/pessoas/1",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/fornecedores/1/dados-bancarios",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/administracao/parametrizacao",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/relatorios",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/settings/profile",
|
||||
"status": 302,
|
||||
"location": "https://compras.tempo.co.mz/login"
|
||||
},
|
||||
{
|
||||
"path": "/candidaturas/1/submissoes/1/documentos/cv",
|
||||
"status": 401
|
||||
}
|
||||
],
|
||||
"public_pages_passed": 21,
|
||||
"private_routes_protected": 7,
|
||||
"server_activation_proof": "pending"
|
||||
},
|
||||
"visual_review": "pending",
|
||||
"real_candidate_submission_in_production": "not performed"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:14:38+00:00",
|
||||
"database": "disposable PostgreSQL",
|
||||
"application_role_reused_without_privilege_change": true,
|
||||
"result": "passed",
|
||||
"test_files": [
|
||||
"CandidateSubmissionsTest",
|
||||
"PeopleHiringTest",
|
||||
"CandidatePortalTest",
|
||||
"CandidateDocumentFormatsTest"
|
||||
],
|
||||
"original_evaluation_untouched": true,
|
||||
"database_removed": true,
|
||||
"tests": 64,
|
||||
"assertions": 1401
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"checked_at": "2026-10-30T19:15:14.138147+00:00",
|
||||
"base_commit": "bf84575d10d9063c2224945e6d5627da826148a9",
|
||||
"scope": [
|
||||
"public published opportunity discovery before candidate registration",
|
||||
"current publication deadline company and process scope",
|
||||
"public content minimisation",
|
||||
"shared public and authenticated pagination",
|
||||
"authenticated application and document guards preserved"
|
||||
],
|
||||
"module_sqlite": {
|
||||
"tests": 24,
|
||||
"assertions": 374,
|
||||
"passed": true
|
||||
},
|
||||
"postgresql": {
|
||||
"tests": 64,
|
||||
"assertions": 1401,
|
||||
"passed": true,
|
||||
"disposable_database_removed": true
|
||||
},
|
||||
"frontend": {
|
||||
"tests": 10,
|
||||
"passed": true
|
||||
},
|
||||
"typescript": true,
|
||||
"phpstan_errors": 0,
|
||||
"pint": true,
|
||||
"targeted_lint": true,
|
||||
"build": {
|
||||
"exit_code": 0,
|
||||
"completed": true
|
||||
},
|
||||
"full_suite": "reuse accepted bf84575 suite; not repeated",
|
||||
"new_migrations": false,
|
||||
"production_installed": false,
|
||||
"visual_review": "blocked by unavailable native pipe and browser host",
|
||||
"source_sha256": {
|
||||
"app/Services/CandidateSubmissions.php": "63bebd4ccb04e9bc2ab19b0701567f082831814498e83cd7e9755ba2f550f6e4",
|
||||
"app/Http/Controllers/CandidatePortalController.php": "e37a7df5933da0d9635e91180a027d1d00d5dd3d16efc15c8c36a3ad906f724d",
|
||||
"resources/js/pages/candidate/entry.tsx": "1fb6c2f3ecd7153fe677c7c8164867df22f0cdc146c0bc32d6c82a0b61cd354e",
|
||||
"resources/js/pages/candidate/profile.tsx": "3857fec4bb8c10164a13b710f95df916707ebb8e214a05117f1f530b0b7b34c5"
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,21 @@ import { Button } from '@/components/ui/button';
|
||||
export default function Entry({
|
||||
company,
|
||||
portal,
|
||||
opportunities = { data: [], prev_page_url: null, next_page_url: null },
|
||||
}: {
|
||||
company: { id: number; name: string };
|
||||
portal: { version: number; registration_open: boolean; notice: string };
|
||||
opportunities?: {
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
kind: string;
|
||||
closes_on: string;
|
||||
description: string;
|
||||
}[];
|
||||
prev_page_url: string | null;
|
||||
next_page_url: string | null;
|
||||
};
|
||||
}) {
|
||||
const login = useForm({ email: '', password: '' });
|
||||
const register = useForm({
|
||||
@@ -61,10 +73,71 @@ export default function Entry({
|
||||
</h1>
|
||||
<p>
|
||||
Prepare o seu perfil e candidate-se às oportunidades
|
||||
publicadas. O envio de anexos ainda está em desenvolvimento.
|
||||
Criar um perfil não constitui candidatura ou contratação.
|
||||
publicadas. Os documentos exigidos são indicados em cada
|
||||
formulário. Criar um perfil não constitui candidatura ou
|
||||
contratação.
|
||||
</p>
|
||||
<section className="space-y-3">
|
||||
<section
|
||||
className="space-y-3"
|
||||
aria-label="Oportunidades publicadas"
|
||||
>
|
||||
<h2 className="text-xl font-semibold">
|
||||
Oportunidades publicadas
|
||||
</h2>
|
||||
{opportunities.data.length === 0 && (
|
||||
<p>Não existem oportunidades nesta página.</p>
|
||||
)}
|
||||
{opportunities.data.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="space-y-2 rounded-lg border p-4"
|
||||
>
|
||||
<h3 className="font-semibold">{item.title}</h3>
|
||||
<p>
|
||||
{item.kind === 'service'
|
||||
? 'Prestação de serviços'
|
||||
: 'Recrutamento'}{' '}
|
||||
· Prazo: {item.closes_on}
|
||||
</p>
|
||||
<details>
|
||||
<summary className="cursor-pointer underline">
|
||||
Consultar descrição publicada
|
||||
</summary>
|
||||
<p className="whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</p>
|
||||
</details>
|
||||
<a
|
||||
className="inline-block underline"
|
||||
href="#candidate-login"
|
||||
>
|
||||
Entrar para preparar a candidatura
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
<nav
|
||||
aria-label="Páginas de oportunidades"
|
||||
className="flex gap-4"
|
||||
>
|
||||
{opportunities.prev_page_url && (
|
||||
<Link
|
||||
href={opportunities.prev_page_url}
|
||||
preserveScroll
|
||||
>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{opportunities.next_page_url && (
|
||||
<Link
|
||||
href={opportunities.next_page_url}
|
||||
preserveScroll
|
||||
>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</section>
|
||||
<section className="space-y-3" id="candidate-login">
|
||||
<h2 className="text-xl font-semibold">Entrar</h2>
|
||||
<form
|
||||
className="space-y-3"
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function Profile({
|
||||
emailDeliveryAvailable = false,
|
||||
accessStatus,
|
||||
opportunities = [],
|
||||
opportunityPagination,
|
||||
submissions = [],
|
||||
}: {
|
||||
companyId: number;
|
||||
@@ -26,6 +27,7 @@ export default function Profile({
|
||||
emailDeliveryAvailable?: boolean;
|
||||
accessStatus?: string | null;
|
||||
opportunities?: { id: number; title: string; closes_on: string }[];
|
||||
opportunityPagination?: { prev: string | null; next: string | null };
|
||||
submissions?: { id: number; reference: string; created_at: string }[];
|
||||
}) {
|
||||
const form = useForm({
|
||||
@@ -52,6 +54,21 @@ export default function Profile({
|
||||
</Link>
|
||||
</p>
|
||||
))}
|
||||
<nav
|
||||
aria-label="Páginas de oportunidades"
|
||||
className="flex gap-4"
|
||||
>
|
||||
{opportunityPagination?.prev && (
|
||||
<Link href={opportunityPagination.prev} preserveScroll>
|
||||
Anterior
|
||||
</Link>
|
||||
)}
|
||||
{opportunityPagination?.next && (
|
||||
<Link href={opportunityPagination.next} preserveScroll>
|
||||
Seguinte
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<h2 className="text-xl font-semibold">
|
||||
As minhas candidaturas
|
||||
</h2>
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SubmissionForms;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -44,6 +45,43 @@ beforeEach(function () {
|
||||
'answers' => ['full_name' => 'Pessoa fictícia', 'availability_location' => $this->profile['availability']]];
|
||||
});
|
||||
|
||||
test('guests see only explicitly published public opportunity fields while submission remains authenticated', function () {
|
||||
Auth::guard('candidate')->logout();
|
||||
Auth::guard('web')->logout();
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1)
|
||||
->where('opportunities.data.0', ['id' => $this->process->id, 'title' => 'Oportunidade fictícia', 'kind' => 'service', 'closes_on' => $this->process->closes_on, 'description' => $this->publication['description']])
|
||||
->where('auth.user', null)->where('organizationAccess', null));
|
||||
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertUnauthorized();
|
||||
$this->postJson($this->base.'/oportunidades/'.$this->process->id, $this->body)->assertUnauthorized();
|
||||
expect(DB::table('candidate_submissions')->count())->toBe(0);
|
||||
$this->actingAs($this->admin);
|
||||
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 1, 'active' => false]);
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
|
||||
app(CandidateSubmissions::class)->publish($this->admin, $this->process->id, [...$this->publication, 'version' => 2]);
|
||||
$this->travel(8)->days();
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
|
||||
});
|
||||
|
||||
test('public opportunity listing is paginated within the company and remains visible to existing users when registration closes', function () {
|
||||
$process = (array) DB::table('people_processes')->where('id', $this->process->id)->first();
|
||||
$publication = (array) DB::table('candidate_opportunity_publications')->where('people_process_id', $this->process->id)->first();
|
||||
unset($process['id'], $publication['id']);
|
||||
for ($index = 0; $index < 12; $index++) {
|
||||
$id = DB::table('people_processes')->insertGetId([...$process, 'operation_key' => (string) Str::uuid()]);
|
||||
DB::table('candidate_opportunity_publications')->insert([...$publication, 'people_process_id' => $id]);
|
||||
}
|
||||
app(CandidateProfiles::class)->configure($this->admin, ['version' => 1, 'registration_open' => false, 'notice' => 'Aviso fictício de registos temporariamente encerrados.', 'reason' => 'Encerrar novos registos no ensaio.']);
|
||||
$this->get($this->base)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 12)->where('opportunities.total', 13)->where('opportunities.next_page_url', $this->base.'?opportunities_page=2')->where('portal.registration_open', false));
|
||||
$this->get($this->base.'?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 1));
|
||||
$this->get($this->base.'/perfil?opportunities_page=2')->assertInertia(fn (Assert $p) => $p->has('opportunities', 1)->where('opportunityPagination.prev', $this->base.'/perfil?opportunities_page=1')->where('opportunityPagination.next', null));
|
||||
$other = Tenant::query()->create(['name' => 'Empresa sem oportunidades']);
|
||||
$otherAdmin = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($otherAdmin);
|
||||
app(CandidateProfiles::class)->configure($otherAdmin, ['version' => 0, 'registration_open' => true, 'notice' => 'Aviso fictício da outra empresa sem oportunidades.', 'reason' => 'Configurar portal separado no ensaio.']);
|
||||
$this->get('/candidaturas/'.$other->id)->assertInertia(fn (Assert $p) => $p->has('opportunities.data', 0));
|
||||
$this->getJson($this->base.'?opportunities_page=invalid')->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('electronic submission returns a scoped receipt and preserves the actual profile and answers', function () {
|
||||
$this->get($this->base.'/oportunidades/'.$this->process->id)->assertInertia(fn (Assert $p) => $p->where('opportunity.description', 'Descrição pública fictícia da oportunidade.')->missing('opportunity.purpose')->missing('opportunity.selection'));
|
||||
$url = $this->base.'/oportunidades/'.$this->process->id;
|
||||
|
||||
@@ -62,6 +62,44 @@ test.each([true, false])(
|
||||
expect(html).not.toContain('/dashboard');
|
||||
},
|
||||
);
|
||||
test('public entry shows published opportunities and pagination while keeping application behind candidate login', () => {
|
||||
const initialPage = {
|
||||
...page,
|
||||
props: {
|
||||
...page.props,
|
||||
company: { id: 1, name: 'Empresa fictícia' },
|
||||
portal: {
|
||||
version: 1,
|
||||
registration_open: false,
|
||||
notice: 'Aviso fictício.',
|
||||
},
|
||||
opportunities: {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
title: 'Serviço publicado',
|
||||
kind: 'service',
|
||||
closes_on: '2026-11-10',
|
||||
description: 'Descrição pública do serviço.',
|
||||
},
|
||||
],
|
||||
prev_page_url: null,
|
||||
next_page_url: '/candidaturas/1?opportunities_page=2',
|
||||
},
|
||||
},
|
||||
};
|
||||
const html = renderToString(
|
||||
<App initialPage={initialPage} initialComponent={Entry} />,
|
||||
);
|
||||
expect(html).toContain('Serviço publicado');
|
||||
expect(html).toContain('Descrição pública do serviço.');
|
||||
expect(html).toContain('Prestação de serviços');
|
||||
expect(html).toContain('href="#candidate-login"');
|
||||
expect(html).toContain('/candidaturas/1?opportunities_page=2');
|
||||
expect(html).not.toContain('/oportunidades/7');
|
||||
expect(html).not.toContain('anexos ainda está em desenvolvimento');
|
||||
});
|
||||
|
||||
test('candidate profile renders geography and retained version without internal evaluation controls', () => {
|
||||
const initialPage = {
|
||||
...page,
|
||||
|
||||
Reference in New Issue
Block a user