Story 3-4: Bulk client notification scheduling — BulkNotificationController, BulkActionBar component, checkbox selection on declarations index. Story 3-5: Email notification enhancement — observer-driven email on en_attente_client, cache invalidation on ferme, workspace branding on all email templates, 11 feature tests. Code review fixes: - Move bulk-notify route above resource wildcard to prevent shadowing - Add static $suppressEmail flag to prevent observer double-sending when DeclarationMessageController already sends the email - Fix canBulkNotify logic (was granting workers access) - Add WorkspaceUserRole check to BulkNotifyRequest::authorize() - Replace firstOrCreate with explicit invitation lookup that syncs client email and handles used/expired invitations correctly - Watch declarations.data instead of current_page to clear selection on filter/sort changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
367 lines
14 KiB
PHP
367 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,
|
|
'bulkNotifyUrl' => route('declarations.bulk-notify'),
|
|
'canBulkNotify' => ! $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');
|
|
}
|
|
}
|