Files
L-Ami-Fiduciaire/app/Http/Controllers/DeclarationController.php
Saad Zoubir c7ecbd0ee7 feat: add one-click nudge system with popover, throttling, and email notifications (Story 3.2)
Add NudgeController with 1-hour throttling per declaration, NudgePopover component
on declarations index and dashboard, shadcn-vue popover primitives, and per-declaration
nudge tracking. Owners/managers can nudge assigned workers with one click.
Includes 10 feature tests covering authorization, throttling, and cache invalidation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:26:22 +01:00

365 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Concerns\HasWorkspaceScope;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Enums\WorkspaceUserRole;
use App\Http\Requests\StoreDeclarationRequest;
use App\Http\Requests\UpdateDeclarationRequest;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\MediaDownload;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DeclarationController extends Controller
{
use HasWorkspaceScope;
protected function declarationTypeLabels(): array
{
return [
DeclarationType::VAT => 'TVA',
DeclarationType::VatMonthly => 'TVA mensuelle',
DeclarationType::VatQuarterly => 'TVA trimestrielle',
DeclarationType::CorporateTax => 'IS',
DeclarationType::IncomeTax => 'IR',
DeclarationType::CNSS => 'CNSS',
DeclarationType::AnnualBalance => 'Bilan',
DeclarationType::Other => 'Autre',
];
}
protected function declarationStatusLabels(): array
{
return DeclarationStatus::labels();
}
protected function declarationPriorityLabels(): array
{
return [
DeclarationPriority::Low => 'Basse',
DeclarationPriority::Medium => 'Normale',
DeclarationPriority::High => 'Haute',
];
}
protected function isWorker(): bool
{
return auth()->user()->currentWorkspaceUser()->role->is(WorkspaceUserRole::Worker);
}
/**
* Display a listing of the declarations.
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$declarations = $workspace->declarations()
->forUser($user, $workspaceUser)
->with(['client', 'assignee'])
->latest()
->paginate($perPage)
->through(fn (Declaration $declaration) => [
'id' => $declaration->id,
'title' => $declaration->title,
'type' => $declaration->type->value,
'client_name' => $declaration->client->company_name,
'assignee_name' => $declaration->assignee?->name,
'status' => $declaration->status->value,
'due_date' => $declaration->due_date?->format('Y-m-d'),
'showUrl' => route('declarations.show', $declaration),
'editUrl' => route('declarations.edit', $declaration),
'destroyUrl' => route('declarations.destroy', $declaration),
'nudgeUrl' => ! $isWorker ? route('declarations.nudge', $declaration) : null,
]);
return Inertia::render('declarations/Index', [
'declarations' => $declarations,
'createUrl' => route('declarations.create'),
'workspaceName' => $workspace->name,
'canCreate' => ! $isWorker,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
'canNudge' => ! $isWorker,
]);
}
/**
* Show the form for creating a new declaration.
*/
public function create(Request $request): Response
{
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$initialClientId = $request->integer('client_id', 0) ?: null;
return Inertia::render('declarations/Create', [
'indexUrl' => route('declarations.index'),
'storeUrl' => route('declarations.store'),
'initialClientId' => $initialClientId,
'declarationTypeLabels' => $this->declarationTypeLabels(),
'declarationStatusLabels' => $this->declarationStatusLabels(),
'declarationPriorityLabels' => $this->declarationPriorityLabels(),
'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [
'id' => $c->id,
'company_name' => $c->company_name,
])->values()->all(),
'workspaceUsers' => $workspace->users()
->orderBy('users.name')
->select('users.id', 'users.name', 'users.email')
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
])->values()->all(),
]);
}
/**
* Store a newly created declaration in storage.
*/
public function store(StoreDeclarationRequest $request): RedirectResponse
{
if ($this->isWorker()) {
abort(404);
}
$workspace = $this->currentWorkspace();
$data = $request->validated();
$data['workspace_id'] = $workspace->id;
$data['created_by'] = $request->user()?->id;
$data['status'] = $data['status'] ?? DeclarationStatus::Created;
if (($data['type'] ?? '') === 'vat_monthly') {
$data['period_quarter'] = null;
} elseif (($data['type'] ?? '') === 'vat_quarterly') {
$data['period_month'] = null;
}
Declaration::query()->create($data);
return to_route('declarations.index');
}
/**
* Display the specified declaration.
*/
public function show(Request $request, Declaration $declaration): Response
{
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
$user = auth()->user();
$workspaceUser = $user->currentWorkspaceUser();
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
if ($isWorker && $declaration->assigned_to !== $user->id) {
abort(404);
}
$declaration->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]);
$allMedia = $declaration->getMedia('documents');
$downloadedMediaIds = MediaDownload::query()
->where('user_id', $request->user()->id)
->whereIn('media_id', $allMedia->pluck('id'))
->pluck('media_id')
->all();
$messages = $declaration->messages->map(function ($m) use ($declaration, $allMedia, $downloadedMediaIds) {
$attachments = $allMedia
->filter(fn ($media) => $media->getCustomProperty('message_id') === $m->id)
->map(fn ($media) => [
'id' => $media->id,
'file_name' => $media->file_name,
'mime_type' => $media->mime_type,
'size' => $media->human_readable_size,
'downloadUrl' => route('declarations.media.download', ['declaration' => $declaration, 'mediaId' => $media->id]),
'is_downloaded' => in_array($media->id, $downloadedMediaIds),
])
->values()
->all();
$confirmationStatus = null;
if ($m->type->value === 'confirmation') {
$confirmationStatus = $declaration->refused_at ? 'refused' : ($declaration->validated_at ? 'confirmed' : 'pending');
}
return [
'id' => $m->id,
'type' => $m->type->value,
'body' => $m->body,
'sent_by_type' => $m->sent_by_type->value,
'sender_name' => $m->sender_name,
'created_at' => $m->created_at->format('Y-m-d H:i'),
'attachments' => $attachments,
'confirmation_status' => $confirmationStatus,
];
})->values()->all();
$documents = $allMedia->map(fn ($m) => [
'id' => $m->id,
'name' => $m->name,
'file_name' => $m->file_name,
'size' => $m->human_readable_size,
'created_at' => $m->created_at->format('d/m/Y H:i'),
'uploaded_by' => $m->getCustomProperty('uploaded_by_type') === 'user' ? 'Comptable' : 'Client',
'downloadUrl' => route('declarations.media.download', ['declaration' => $declaration, 'mediaId' => $m->id]),
'is_downloaded' => in_array($m->id, $downloadedMediaIds),
])->values()->all();
$canMention = ! $isWorker;
return Inertia::render('declarations/Show', [
'declaration' => [
'id' => $declaration->id,
'title' => $declaration->title,
'type' => $declaration->type->value,
'client_id' => $declaration->client_id,
'client_name' => $declaration->client->company_name,
'period_year' => $declaration->period_year,
'period_month' => $declaration->period_month,
'period_quarter' => $declaration->period_quarter,
'due_date' => $declaration->due_date?->format('Y-m-d'),
'status' => $declaration->status->value,
'priority' => $declaration->priority?->value,
'assigned_to' => $declaration->assigned_to,
'assignee_name' => $declaration->assignee?->name,
'validated_at' => $declaration->validated_at?->format('Y-m-d H:i'),
'closed_at' => $declaration->closed_at?->format('Y-m-d H:i'),
'notes_internal' => $declaration->notes_internal,
'notes_client' => $declaration->notes_client,
'created_at' => $declaration->created_at?->format('Y-m-d H:i'),
],
'messages' => $messages,
'documents' => $documents,
'messagesStoreUrl' => route('declarations.messages.store', $declaration),
'mediaStoreUrl' => route('declarations.media.store', $declaration),
'messageTypeLabels' => [
'invite' => 'Invitation',
'situation' => 'Situation',
'file_request' => 'Demande de pièces',
'confirmation' => 'Demande de validation',
'text' => 'Message',
],
'indexUrl' => route('declarations.index'),
'editUrl' => route('declarations.edit', $declaration),
'workspaceUsers' => $workspace->users()->orderBy('users.name')
->select('users.id', 'users.name', 'users.email')
->get()->map(fn ($u) => ['id' => $u->id, 'name' => $u->name])
->values()->all(),
'mentionStoreUrl' => route('declarations.mentions.store', $declaration),
'canMention' => $canMention,
'canEdit' => ! $isWorker,
'canDelete' => ! $isWorker,
]);
}
/**
* Show the form for editing the specified declaration.
*/
public function edit(Request $request, Declaration $declaration): Response
{
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$workspace = $this->currentWorkspace();
return Inertia::render('declarations/Edit', [
'declaration' => [
'id' => $declaration->id,
'title' => $declaration->title,
'type' => $declaration->type->value,
'client_id' => $declaration->client_id,
'period_year' => $declaration->period_year,
'period_month' => $declaration->period_month,
'period_quarter' => $declaration->period_quarter,
'due_date' => $declaration->due_date?->format('Y-m-d'),
'status' => $declaration->status->value,
'priority' => $declaration->priority?->value,
'assigned_to' => $declaration->assigned_to,
'notes_internal' => $declaration->notes_internal,
'notes_client' => $declaration->notes_client,
'created_at' => $declaration->created_at?->format('Y-m-d H:i'),
],
'indexUrl' => route('declarations.index'),
'updateUrl' => route('declarations.update', $declaration),
'declarationTypeLabels' => $this->declarationTypeLabels(),
'declarationStatusLabels' => $this->declarationStatusLabels(),
'declarationPriorityLabels' => $this->declarationPriorityLabels(),
'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [
'id' => $c->id,
'company_name' => $c->company_name,
])->values()->all(),
'workspaceUsers' => $workspace->users()
->orderBy('users.name')
->select('users.id', 'users.name', 'users.email')
->get()
->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
])->values()->all(),
]);
}
/**
* Update the specified declaration in storage.
*/
public function update(UpdateDeclarationRequest $request, Declaration $declaration): RedirectResponse
{
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$data = $request->validated();
if (($data['type'] ?? '') === 'vat_monthly') {
$data['period_quarter'] = null;
} elseif (($data['type'] ?? '') === 'vat_quarterly') {
$data['period_month'] = null;
}
$declaration->update($data);
return to_route('declarations.index');
}
/**
* Remove the specified declaration from storage.
*/
public function destroy(Request $request, Declaration $declaration): RedirectResponse
{
if ($this->isWorker()) {
abort(404);
}
$this->authorizeWorkspaceAccess($declaration);
$declaration->delete();
return to_route('declarations.index');
}
}