feat(desk): print authorized saved pre-dossier

This commit is contained in:
2026-11-10 10:41:49 +02:00
parent 4e07557cb1
commit dcecd25102
5 changed files with 157 additions and 1 deletions
+18 -1
View File
@@ -78,6 +78,23 @@ class VirtualDeskController extends Controller
}
public function showCase(Request $request, string $id, VirtualDesk $desk): JsonResponse|Response
{
$data = $this->caseViewData($request, $id, $desk);
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
}
public function preDossier(Request $request, string $id, VirtualDesk $desk): \Illuminate\Http\Response
{
return response()->view('desk.pre-dossier', $this->caseViewData($request, $id, $desk), 200, [
'Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff',
]);
}
/** Same authorised projection for the workspace and its preparation document.
* @return array<string,mixed>
*/
private function caseViewData(Request $request, string $id, VirtualDesk $desk): array
{
$case = $desk->case($request->user(), $id);
$owner = $desk->owns($request->user(), $case, false);
@@ -98,7 +115,7 @@ class VirtualDeskController extends Controller
'links' => DB::table('desk_institutional_links')->get()->map(fn ($link) => [...(array) $link, 'monitor' => app(InstitutionalLinkMonitor::class)->status($link)]), 'source' => config('virtual-desk.source'), 'support' => config('portal.support'),
'guidance' => app(InvestorGuidance::class)->installed() ? app(InvestorGuidance::class)->view($request->user(), $id) : null];
return $request->expectsJson() ? response()->json($data) : Inertia::render('desk/case', $data);
return $data;
}
/** @return Collection<int,\stdClass> */
+9
View File
@@ -260,6 +260,15 @@ export default function Case({
: 'por associar'}
.
</p>
<a
className="underline"
href={`/balcao-virtual/dossiers/${dossier.id}/pre-dossier`}
target="_blank"
rel="noopener noreferrer"
>
Pré-dossiê dos dados guardados — imprimir/guardar PDF (novo
separador)
</a>
{guidance ? (
<InvestorGuidance
caseId={dossier.id}
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="pt-MZ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Pré-dossiê — {{ $case['title'] }} — InvesteMoz</title>
<style>
@page { size: A4; margin: 18mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #172e29; background: #f3f6f5; font: 16px/1.5 Arial, sans-serif; }
main { max-width: 960px; margin: 24px auto; padding: 32px; background: white; }
h1 { font-size: 28px; } h2 { margin-top: 28px; font-size: 21px; break-after: avoid; }
p, pre, td, th { overflow-wrap: anywhere; }
pre { white-space: pre-wrap; font: inherit; }
table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #d9d9d9; padding: 10px; text-align: left; vertical-align: top; }
th { background: #edf4f1; } tr { break-inside: avoid; }
.notice { padding: 14px; border-left: 4px solid #b57918; background: #fff8e8; }
nav { display: flex; gap: 20px; flex-wrap: wrap; } button, a { font: inherit; color: #125a4b; }
@media (max-width: 600px) { main { margin: 0; padding: 18px; } }
@media print { body { background: white; font-size: 10.5pt; } main { margin: 0; padding: 0; } nav { display: none; } }
</style>
</head>
<body><main>
<nav aria-label="Acções do pré-dossiê">
<button type="button" onclick="window.print()">Imprimir / guardar como PDF</button>
<a href="{{ url('/balcao-virtual/dossiers/'.$case['id']) }}">Voltar ao mesmo dossier</a>
</nav>
<header>
<p>InvesteMoz · {{ config('portal.operator_name') ?: 'Tempo, Lda.' }}</p>
<h1>Pré-dossiê de preparação do investimento</h1>
<p>{{ $case['title'] }}</p>
<p>Dossier {{ $case['id'] }} · Versão {{ $case['version'] }} · {{ empty($case['closed_at']) ? 'Aberto' : 'Encerrado' }}</p>
<p>Consulta em {{ now('Africa/Maputo')->format('d/m/Y H:i') }} (CAT, UTC+2).</p>
</header>
<p class="notice">Documento de preparação com os dados actualmente acessíveis nesta conta. Não constitui aprovação de investimento, concessão de benefícios fiscais, licença, certificação da APIEX ou submissão oficial a uma entidade pública. As lacunas permanecem por preencher; a impressão não altera o dossier ou os seus pedidos.</p>
@foreach(['profile' => 'Perfil declarado', 'company' => 'Entidade declarada', 'investment' => 'Investimento declarado'] as $key => $label)
@if(isset($case['payload'][$key]))
<section><h2>{{ $label }}</h2><pre>{{ is_array($case['payload'][$key]) ? json_encode($case['payload'][$key], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : $case['payload'][$key] }}</pre></section>
@endif
@endforeach
@if(!empty($case['simulation_id']))
<section><h2>Preparação fiscal associada</h2><p>Referência: {{ $case['simulation_id'] }}. A associação identifica a preparação existente; não confirma regras, elegibilidade ou benefícios. Consulte as fontes e versões no simulador antes de utilizar um resultado.</p></section>
@endif
<section>
<h2>Pedidos no mesmo Balcão</h2>
<table><thead><tr><th>Referência</th><th>Estado</th><th>Versão</th></tr></thead><tbody>
@forelse($requests as $item)
<tr><td>{{ $item->protocol ?: 'Rascunho — sem submissão' }}</td><td>{{ $states[$item->status] ?? $item->status }}</td><td>{{ $item->version }}</td></tr>
@empty
<tr><td colspan="3">Ainda sem pedidos acessíveis neste dossier.</td></tr>
@endforelse
</tbody></table>
</section>
<section>
<h2>Documentos acessíveis</h2>
<p>A relação não anexa ficheiros nem confirma validade jurídica ou aprovação.</p>
<ul>@forelse($documents as $document)
<li>{{ $document->file_name ?? $document->title ?? 'Documento' }} · {{ $document->category ?? 'Categoria não indicada' }}</li>
@empty
<li>Ainda sem documentos acessíveis.</li>
@endforelse</ul>
</section>
<footer><p>Os dados em falta não foram substituídos por estimativas. Conserve a data e a versão desta cópia; alterações posteriores exigem nova consulta.</p></footer>
</main></body>
</html>
+1
View File
@@ -18,6 +18,7 @@ Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class])->group
Route::get('contextos', [Guidance::class, 'contexts']);
Route::post('dossiers', [Desk::class, 'createCase']);
Route::get('dossiers/{id}', [Desk::class, 'showCase'])->whereUuid('id');
Route::get('dossiers/{id}/pre-dossier', [Desk::class, 'preDossier'])->whereUuid('id');
Route::put('dossiers/{id}/estado', [Desk::class, 'caseState'])->whereUuid('id');
Route::post('dossiers/{id}/etapas', [Desk::class, 'stage'])->whereUuid('id');
Route::post('dossiers/{id}/pedidos', [Desk::class, 'createRequest'])->whereUuid('id');
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\VirtualDeskController;
use App\Http\Middleware\VirtualDeskInstalled;
use App\Models\User;
use App\Services\VirtualDesk;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Mockery;
use Symfony\Component\HttpKernel\Exception\HttpException;
class DeskPreDossierTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->never();
}
public function test_export_reuses_the_existing_case_authorisation_before_reading_any_data(): void
{
$actor = new User;
$actor->id = 9;
$id = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
$request = Request::create('/balcao-virtual/dossiers/'.$id.'/pre-dossier');
$request->setUserResolver(fn () => $actor);
$desk = Mockery::mock(VirtualDesk::class);
$desk->shouldReceive('case')->once()->with($actor, $id)->andThrow(new HttpException(403));
$this->expectException(HttpException::class);
app(VirtualDeskController::class)->preDossier($request, $id, $desk);
}
public function test_preparation_document_escapes_declared_data_and_preserves_unknowns_and_internal_status(): void
{
$html = view('desk.pre-dossier', [
'case' => ['id' => 'qa-case', 'title' => '<script>alert(1)</script>', 'version' => 3, 'closed_at' => null,
'payload' => ['profile' => ['name' => '<img src=x onerror=alert(2)>'], 'investment' => ['amount' => null]], 'simulation_id' => 'qa-existing-simulation'],
'requests' => collect([(object) ['protocol' => null, 'status' => 'draft', 'version' => 1]]),
'documents' => collect([(object) ['file_name' => '<script>document</script>', 'category' => 'licença declarada']]),
'states' => ['draft' => 'Rascunho'],
])->render();
$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
$this->assertStringContainsString('&lt;img', $html);
$this->assertStringContainsString('Rascunho — sem submissão', $html);
$this->assertStringContainsString('window.print()', $html);
$this->assertStringContainsString('Não constitui aprovação', $html);
$this->assertStringContainsString('não confirma regras, elegibilidade ou benefícios', $html);
$this->assertStringContainsString('null', $html);
}
public function test_preparation_route_keeps_the_authenticated_installed_workspace_gates(): void
{
$route = app('router')->getRoutes()->match(Request::create('/balcao-virtual/dossiers/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/pre-dossier'));
$middleware = $route->gatherMiddleware();
$this->assertContains('auth', $middleware);
$this->assertContains('verified', $middleware);
$this->assertContains(VirtualDeskInstalled::class, $middleware);
}
}