feat: complete Epic 0 — foundation migration & infrastructure setup

Stories 0.2-0.5: rename folders→declarations (backend+frontend), configure
Redis for cache/queue/sessions, add foundation database migrations
(permissions, archived_at), replace DeclarationStatus enum with architecture
lifecycle values, create DeclarationObserver for status transition validation
and auto-archive, fix controller status transitions to respect observer rules.

93 tests pass (240 assertions).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 18:25:32 +00:00
parent d380df4074
commit fd43a6f429
105 changed files with 3899 additions and 1558 deletions

View File

@@ -4,7 +4,7 @@ namespace App\Enums;
use BenSampo\Enum\Enum;
final class FolderPriority extends Enum
final class DeclarationPriority extends Enum
{
const Low = 'low';

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class DeclarationStatus extends Enum
{
const Created = 'created';
const EnCours = 'en_cours';
const EnAttenteClient = 'en_attente_client';
const Termine = 'termine';
const Ferme = 'ferme';
/**
* Get French display labels for each status.
*
* @return array<string, string>
*/
public static function labels(): array
{
return [
self::Created => 'Créé',
self::EnCours => 'En cours',
self::EnAttenteClient => 'En attente client',
self::Termine => 'Terminé',
self::Ferme => 'Fermé',
];
}
/**
* Get the valid next statuses for each status per the architecture status flow.
*
* @return array<string, list<string>>
*/
public static function allowedTransitions(): array
{
return [
self::Created => [self::EnCours],
self::EnCours => [self::EnAttenteClient, self::Termine],
self::EnAttenteClient => [self::EnCours],
self::Termine => [self::Ferme],
self::Ferme => [],
];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Enums;
use BenSampo\Enum\Enum;
final class FolderType extends Enum
final class DeclarationType extends Enum
{
const VAT = 'vat';

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class FolderStatus extends Enum
{
const Draft = 'draft';
const WaitingDocuments = 'waiting_documents';
const DocumentsReceived = 'documents_received';
const Processing = 'processing';
const AdditionalDocumentsRequested = 'additional_documents_requested';
const WaitingClientValidation = 'waiting_client_validation';
const Validated = 'validated';
const Closed = 'closed';
const Cancelled = 'cancelled';
}

View File

@@ -16,16 +16,16 @@ class ConfirmController extends Controller
*/
public function show(Request $request, string $token): Response
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$folder->load(['client']);
$declaration->load(['client']);
return Inertia::render('client/Confirm', [
'folder' => [
'id' => $folder->id,
'title' => $folder->title,
'client_name' => $folder->client->company_name,
'declaration' => [
'id' => $declaration->id,
'title' => $declaration->title,
'client_name' => $declaration->client->company_name,
],
'token' => $token,
'submitUrl' => route('client.confirm.store', ['token' => $token]),
@@ -37,19 +37,19 @@ class ConfirmController extends Controller
*/
public function store(Request $request, string $token): RedirectResponse
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$request->validate([
'signature' => ['required', 'string', 'max:255'],
]);
$folder->update([
$declaration->update([
'validated_at' => now(),
'confirmed_by_type' => ActorType::Client,
'confirmed_by_id' => $folder->client_id,
'confirmed_by_id' => $declaration->client_id,
'confirmation_signature' => $request->input('signature'),
'status' => \App\Enums\FolderStatus::Validated,
'status' => \App\Enums\DeclarationStatus::EnCours,
]);
return back()->with('flash', ['type' => 'success', 'message' => 'Validation enregistrée. Merci.']);

View File

@@ -15,16 +15,16 @@ class RefuseController extends Controller
*/
public function show(Request $request, string $token): Response
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$folder->load(['client']);
$declaration->load(['client']);
return Inertia::render('client/Refuse', [
'folder' => [
'id' => $folder->id,
'title' => $folder->title,
'client_name' => $folder->client->company_name,
'declaration' => [
'id' => $declaration->id,
'title' => $declaration->title,
'client_name' => $declaration->client->company_name,
],
'token' => $token,
'submitUrl' => route('client.refuse.store', ['token' => $token]),
@@ -36,14 +36,14 @@ class RefuseController extends Controller
*/
public function store(Request $request, string $token): RedirectResponse
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$request->validate([
'reason' => ['nullable', 'string', 'max:65535'],
]);
$folder->update([
$declaration->update([
'refused_at' => now(),
'refusal_reason' => $request->input('reason'),
]);

View File

@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Client;
use App\Enums\ActorType;
use App\Enums\FolderStatus;
use App\Enums\DeclarationStatus;
use App\Enums\MessageType;
use App\Http\Controllers\Controller;
use App\Mail\FolderTextMessageMail;
use App\Mail\DeclarationTextMessageMail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
@@ -20,11 +20,11 @@ class UploadController extends Controller
*/
public function show(Request $request, string $token): Response
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$folder->load(['client']);
$documents = $folder->getMedia('documents')->map(fn ($m) => [
$declaration->load(['client']);
$documents = $declaration->getMedia('documents')->map(fn ($m) => [
'id' => $m->id,
'name' => $m->name,
'file_name' => $m->file_name,
@@ -33,10 +33,10 @@ class UploadController extends Controller
])->values()->all();
return Inertia::render('client/Upload', [
'folder' => [
'id' => $folder->id,
'title' => $folder->title,
'client_name' => $folder->client->company_name,
'declaration' => [
'id' => $declaration->id,
'title' => $declaration->title,
'client_name' => $declaration->client->company_name,
],
'token' => $token,
'documents' => $documents,
@@ -50,38 +50,42 @@ class UploadController extends Controller
*/
public function store(Request $request, string $token): RedirectResponse
{
$invitation = $request->attributes->get('folder_invitation');
$folder = $invitation->folder;
$invitation = $request->attributes->get('declaration_invitation');
$declaration = $invitation->declaration;
$request->validate([
'files' => ['required', 'array', 'min:1'],
'files.*' => ['file', 'max:10240'],
]);
$message = $folder->messages()->create([
$message = $declaration->messages()->create([
'type' => MessageType::Text,
'body' => 'Documents déposés par le client.',
'sent_by_type' => ActorType::Client,
'sent_by_id' => $folder->client_id,
'sent_by_id' => $declaration->client_id,
'metadata' => ['invitation_id' => $invitation->id],
]);
foreach ($request->file('files') as $file) {
$folder->addMedia($file)
$declaration->addMedia($file)
->withCustomProperties([
'message_id' => $message->id,
'uploaded_by_type' => ActorType::Client,
'uploaded_by_id' => $folder->client_id,
'uploaded_by_id' => $declaration->client_id,
])
->toMediaCollection('documents');
}
$folder->update(['status' => FolderStatus::DocumentsReceived]);
// Only transition to en_cours if the current status allows it
$allowed = DeclarationStatus::allowedTransitions()[$declaration->status->value] ?? [];
if (in_array(DeclarationStatus::EnCours, $allowed)) {
$declaration->update(['status' => DeclarationStatus::EnCours]);
}
$recipient = $folder->assignee ?? $folder->creator;
$recipient = $declaration->assignee ?? $declaration->creator;
if ($recipient?->email) {
Mail::to($recipient->email)->send(
new FolderTextMessageMail($folder, 'Le client a déposé des documents.', null)
new DeclarationTextMessageMail($declaration, 'Le client a déposé des documents.', null)
);
}

View File

@@ -141,7 +141,7 @@ class ClientController extends Controller
$client->load(['internalResponsible', 'contacts']);
$folders = $client->folders()
$declarations = $client->declarations()
->with(['assignee'])
->latest()
->limit(50)
@@ -153,18 +153,18 @@ class ClientController extends Controller
'status' => $f->status->value,
'due_date' => $f->due_date?->format('Y-m-d'),
'created_at' => $f->created_at->format('Y-m-d'),
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->values()
->all();
$allFolders = $client->folders()->get();
$allDeclarations = $client->declarations()->get();
$stats = [
'total' => $allFolders->count(),
'by_status' => $allFolders->groupBy(fn ($f) => $f->status->value)
'total' => $allDeclarations->count(),
'by_status' => $allDeclarations->groupBy(fn ($f) => $f->status->value)
->map->count()
->all(),
'by_type' => $allFolders->groupBy(fn ($f) => $f->type->value)
'by_type' => $allDeclarations->groupBy(fn ($f) => $f->type->value)
->map->count()
->all(),
];
@@ -185,11 +185,11 @@ class ClientController extends Controller
'status' => $client->status?->value,
'internal_notes' => $client->internal_notes,
],
'folders' => $folders,
'declarations' => $declarations,
'stats' => $stats,
'indexUrl' => route('clients.index'),
'editUrl' => route('clients.edit', $client),
'createFolderUrl' => route('folders.create', ['client_id' => $client->id]),
'createDeclarationUrl' => route('declarations.create', ['client_id' => $client->id]),
]);
}

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers;
use App\Enums\FolderStatus;
use App\Models\Folder;
use App\Enums\DeclarationStatus;
use App\Models\Declaration;
use App\Models\Workspace;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -12,7 +12,7 @@ use Inertia\Response;
class DashboardController extends Controller
{
/**
* Display the dashboard with assigned folders and notifications.
* Display the dashboard with assigned declarations and notifications.
*/
public function __invoke(Request $request): Response
{
@@ -20,18 +20,18 @@ class DashboardController extends Controller
$workspaceId = $request->session()->get('current_workspace_id');
$workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null;
$assignedFolders = [];
$assignedDeclarations = [];
$notifications = [];
if ($workspace && $user) {
$assignedFolders = $workspace->folders()
$assignedDeclarations = $workspace->declarations()
->where('assigned_to', $user->id)
->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled])
->whereNotIn('status', [DeclarationStatus::Ferme])
->with('client:id,company_name')
->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC')
->limit(50)
->get()
->map(fn (Folder $f) => [
->map(fn (Declaration $f) => [
'id' => $f->id,
'title' => $f->title,
'type' => $f->type->value,
@@ -39,71 +39,71 @@ class DashboardController extends Controller
'status' => $f->status->value,
'due_date' => $f->due_date?->format('Y-m-d'),
'priority' => $f->priority?->value,
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->all();
$overdue = $workspace->folders()
$overdue = $workspace->declarations()
->where('assigned_to', $user->id)
->where('due_date', '<', now()->startOfDay())
->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled])
->whereNotIn('status', [DeclarationStatus::Ferme])
->with('client:id,company_name')
->orderBy('due_date')
->limit(10)
->get()
->map(fn (Folder $f) => [
->map(fn (Declaration $f) => [
'id' => $f->id,
'title' => $f->title,
'client_name' => $f->client->company_name,
'due_date' => $f->due_date?->format('Y-m-d'),
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->all();
$dueSoon = $workspace->folders()
$dueSoon = $workspace->declarations()
->where('assigned_to', $user->id)
->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()])
->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled])
->whereNotIn('status', [DeclarationStatus::Ferme])
->with('client:id,company_name')
->orderBy('due_date')
->limit(10)
->get()
->map(fn (Folder $f) => [
->map(fn (Declaration $f) => [
'id' => $f->id,
'title' => $f->title,
'client_name' => $f->client->company_name,
'due_date' => $f->due_date?->format('Y-m-d'),
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->all();
$documentsReceived = $workspace->folders()
$documentsReceived = $workspace->declarations()
->where('assigned_to', $user->id)
->where('status', FolderStatus::DocumentsReceived)
->where('status', DeclarationStatus::EnCours)
->with('client:id,company_name')
->orderBy('updated_at', 'desc')
->limit(10)
->get()
->map(fn (Folder $f) => [
->map(fn (Declaration $f) => [
'id' => $f->id,
'title' => $f->title,
'client_name' => $f->client->company_name,
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->all();
$awaitingValidation = $workspace->folders()
$awaitingValidation = $workspace->declarations()
->where('assigned_to', $user->id)
->where('status', FolderStatus::WaitingClientValidation)
->where('status', DeclarationStatus::EnAttenteClient)
->with('client:id,company_name')
->orderBy('confirmation_requested_at', 'desc')
->limit(10)
->get()
->map(fn (Folder $f) => [
->map(fn (Declaration $f) => [
'id' => $f->id,
'title' => $f->title,
'client_name' => $f->client->company_name,
'showUrl' => route('folders.show', $f),
'showUrl' => route('declarations.show', $f),
])
->all();
@@ -116,10 +116,10 @@ class DashboardController extends Controller
}
return Inertia::render('Dashboard', [
'assignedFolders' => $assignedFolders,
'assignedDeclarations' => $assignedDeclarations,
'notifications' => $notifications,
'workspaceName' => $workspace?->name ?? null,
'foldersUrl' => $workspace ? route('folders.index') : null,
'declarationsUrl' => $workspace ? route('declarations.index') : null,
'clientsUrl' => $workspace ? route('clients.index') : null,
]);
}

View File

@@ -0,0 +1,332 @@
<?php
namespace App\Http\Controllers;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use App\Http\Requests\StoreDeclarationRequest;
use App\Http\Requests\UpdateDeclarationRequest;
use App\Models\Client;
use App\Models\Declaration;
use App\Models\MediaDownload;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DeclarationController extends Controller
{
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 currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
/**
* Display a listing of the declarations.
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$declarations = $workspace->declarations()
->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,
'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),
]);
return Inertia::render('declarations/Index', [
'declarations' => $declarations,
'createUrl' => route('declarations.create'),
'workspaceName' => $workspace->name,
]);
}
/**
* Show the form for creating a new declaration.
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$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
{
$workspace = $this->currentWorkspace($request);
$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
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
$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();
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' => in_array(
$workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value,
['owner', 'manager']
),
]);
}
/**
* Show the form for editing the specified declaration.
*/
public function edit(Request $request, Declaration $declaration): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
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
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $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
{
$workspace = $this->currentWorkspace($request);
$this->authorizeDeclaration($workspace, $declaration);
$declaration->delete();
return to_route('declarations.index');
}
protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void
{
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
}
}

View File

@@ -3,7 +3,7 @@
namespace App\Http\Controllers;
use App\Enums\ActorType;
use App\Models\Folder;
use App\Models\Declaration;
use App\Models\MediaDownload;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Symfony\Component\HttpFoundation\Response;
class FolderMediaController extends Controller
class DeclarationMediaController extends Controller
{
protected function currentWorkspace(Request $request): Workspace
{
@@ -23,11 +23,11 @@ class FolderMediaController extends Controller
/**
* Store a newly uploaded file.
*/
public function store(Request $request, Folder $folder): RedirectResponse
public function store(Request $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($folder->workspace_id !== $workspace->id) {
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
@@ -39,7 +39,7 @@ class FolderMediaController extends Controller
$user = $request->user();
foreach ($request->file('files') as $file) {
$folder->addMedia($file)
$declaration->addMedia($file)
->withCustomProperties([
'uploaded_by_type' => ActorType::User,
'uploaded_by_id' => $user->id,
@@ -53,17 +53,17 @@ class FolderMediaController extends Controller
/**
* Download a media file.
*/
public function download(Request $request, Folder $folder, int $mediaId): Response
public function download(Request $request, Declaration $declaration, int $mediaId): Response
{
$workspace = $this->currentWorkspace($request);
if ($folder->workspace_id !== $workspace->id) {
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
$media = Media::query()
->where('model_type', Folder::class)
->where('model_id', $folder->id)
->where('model_type', Declaration::class)
->where('model_id', $declaration->id)
->where('id', $mediaId)
->firstOrFail();

View File

@@ -2,16 +2,16 @@
namespace App\Http\Controllers;
use App\Http\Requests\StoreFolderMentionRequest;
use App\Models\Folder;
use App\Http\Requests\StoreDeclarationMentionRequest;
use App\Models\Declaration;
use App\Models\User;
use App\Models\Workspace;
use App\Notifications\FolderMentionNotification;
use App\Notifications\DeclarationMentionNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class FolderMentionController extends Controller
class DeclarationMentionController extends Controller
{
protected function currentWorkspace(Request $request): Workspace
{
@@ -20,17 +20,17 @@ class FolderMentionController extends Controller
return Workspace::query()->findOrFail($workspaceId);
}
protected function authorizeFolder(Workspace $workspace, Folder $folder): void
protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void
{
if ($folder->workspace_id !== $workspace->id) {
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
}
public function store(StoreFolderMentionRequest $request, Folder $folder): RedirectResponse
public function store(StoreDeclarationMentionRequest $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeFolder($workspace, $folder);
$this->authorizeDeclaration($workspace, $declaration);
$userRole = $workspace->users()
->where('users.id', $request->user()->id)
@@ -46,8 +46,8 @@ class FolderMentionController extends Controller
$validated = $request->validated();
$targetUser = User::findOrFail($validated['user_id']);
$targetUser->notify(new FolderMentionNotification(
$folder,
$targetUser->notify(new DeclarationMentionNotification(
$declaration,
$request->user(),
$validated['message'],
));

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ActorType;
use App\Enums\DeclarationStatus;
use App\Enums\MessageType;
use App\Http\Requests\StoreDeclarationMessageRequest;
use App\Mail\DeclarationConfirmationMail;
use App\Mail\DeclarationFileRequestMail;
use App\Mail\DeclarationInviteMail;
use App\Mail\DeclarationSituationMail;
use App\Mail\DeclarationTextMessageMail;
use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use App\Models\Message;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class DeclarationMessageController extends Controller
{
protected function currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
/**
* Store a newly created message.
*/
public function store(StoreDeclarationMessageRequest $request, Declaration $declaration): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($declaration->workspace_id !== $workspace->id) {
abort(404);
}
$user = $request->user();
$type = MessageType::fromValue($request->input('type'));
$body = $request->input('body');
$invitation = $type->is(MessageType::Invite)
? $this->createInvitation($declaration)
: $this->getOrCreateInvitation($declaration);
$metadata = ['invitation_id' => $invitation->id];
$message = $declaration->messages()->create([
'type' => $type,
'body' => $body,
'sent_by_type' => ActorType::User,
'sent_by_id' => $user->id,
'metadata' => $metadata,
]);
$mediaIds = [];
if ($request->hasFile('files')) {
foreach ($request->file('files') as $file) {
$media = $declaration->addMedia($file)
->withCustomProperties([
'message_id' => $message->id,
'uploaded_by_type' => ActorType::User,
'uploaded_by_id' => $user->id,
])
->toMediaCollection('documents');
$mediaIds[] = $media->id;
}
$message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]);
}
$this->updateDeclarationStatusAndConfirmation($declaration, $type, $mediaIds);
$emailSent = $this->sendEmailForMessage($declaration, $invitation, $message, $body, $type);
$flashMessage = $emailSent
? 'Message envoyé.'
: 'Message enregistré, mais l\'email du client n\'est pas configuré.';
return back()->with('flash', ['type' => 'success', 'message' => $flashMessage]);
}
protected function createInvitation(Declaration $declaration): DeclarationInvitation
{
$declaration->load('client.primaryContact');
return $declaration->invitations()->create([
'email' => $declaration->client->primary_contact_email,
'expires_at' => Carbon::now()->addDays(7),
]);
}
protected function getOrCreateInvitation(Declaration $declaration): DeclarationInvitation
{
$invitation = $declaration->invitations()
->where('expires_at', '>', now())
->latest()
->first();
if ($invitation) {
return $invitation;
}
return $this->createInvitation($declaration);
}
/**
* @param array<int> $mediaIds
*/
protected function updateDeclarationStatusAndConfirmation(Declaration $declaration, MessageType $type, array $mediaIds): void
{
// Transition through en_cours first if declaration is still in created status,
// since created → en_attente_client is not a valid direct transition.
if ($declaration->status->is(DeclarationStatus::Created)) {
$declaration->update(['status' => DeclarationStatus::EnCours]);
$declaration->refresh();
}
match ($type->value) {
'invite' => $declaration->update(['status' => DeclarationStatus::EnAttenteClient]),
'situation', 'file_request' => $declaration->update(['status' => DeclarationStatus::EnAttenteClient]),
'confirmation' => $declaration->update([
'status' => DeclarationStatus::EnAttenteClient,
'confirmation_requested_at' => now(),
'confirmation_media_id' => $mediaIds[0] ?? null,
]),
default => null,
};
}
protected function sendEmailForMessage(Declaration $declaration, DeclarationInvitation $invitation, Message $message, string $body, MessageType $type): bool
{
$declaration->load('client.primaryContact');
$clientEmail = $declaration->client->primary_contact_email;
if (empty($clientEmail)) {
\Illuminate\Support\Facades\Log::warning("No primary contact email for client #{$declaration->client_id}, skipping email.");
return false;
}
match ($type->value) {
'invite' => Mail::to($clientEmail)->send(new DeclarationInviteMail($declaration, $invitation)),
'situation' => Mail::to($clientEmail)->send(new DeclarationSituationMail($declaration, $invitation, $body)),
'file_request' => Mail::to($clientEmail)->send(new DeclarationFileRequestMail($declaration, $invitation, $body)),
'confirmation' => Mail::to($clientEmail)->send(new DeclarationConfirmationMail($declaration, $invitation, $body)),
'text' => Mail::to($clientEmail)->send(new DeclarationTextMessageMail($declaration, $body, $invitation->token)),
default => null,
};
return true;
}
}

View File

@@ -1,328 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\FolderPriority;
use App\Enums\FolderStatus;
use App\Enums\FolderType;
use App\Http\Requests\StoreFolderRequest;
use App\Http\Requests\UpdateFolderRequest;
use App\Models\Client;
use App\Models\Folder;
use App\Models\MediaDownload;
use App\Models\Workspace;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class FolderController extends Controller
{
protected function folderTypeLabels(): array
{
return [
FolderType::VAT => 'TVA',
FolderType::VatMonthly => 'TVA mensuelle',
FolderType::VatQuarterly => 'TVA trimestrielle',
FolderType::CorporateTax => 'IS',
FolderType::IncomeTax => 'IR',
FolderType::CNSS => 'CNSS',
FolderType::AnnualBalance => 'Bilan',
FolderType::Other => 'Autre',
];
}
protected function folderStatusLabels(): array
{
return [
FolderStatus::Draft => 'Brouillon',
FolderStatus::WaitingDocuments => 'En attente documents',
FolderStatus::DocumentsReceived => 'Documents reçus',
FolderStatus::Processing => 'En cours de traitement',
FolderStatus::AdditionalDocumentsRequested => 'Pièces complémentaires demandées',
FolderStatus::WaitingClientValidation => 'En attente validation client',
FolderStatus::Validated => 'Validé',
FolderStatus::Closed => 'Clôturé',
FolderStatus::Cancelled => 'Annulé',
];
}
protected function folderPriorityLabels(): array
{
return [
FolderPriority::Low => 'Basse',
FolderPriority::Medium => 'Normale',
FolderPriority::High => 'Haute',
];
}
protected function currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
/**
* Display a listing of the folders.
*/
public function index(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$perPage = min(max((int) $request->input('per_page', 10), 10), 100);
$folders = $workspace->folders()
->with(['client', 'assignee'])
->latest()
->paginate($perPage)
->through(fn (Folder $folder) => [
'id' => $folder->id,
'title' => $folder->title,
'type' => $folder->type->value,
'client_name' => $folder->client->company_name,
'status' => $folder->status->value,
'due_date' => $folder->due_date?->format('Y-m-d'),
'showUrl' => route('folders.show', $folder),
'editUrl' => route('folders.edit', $folder),
'destroyUrl' => route('folders.destroy', $folder),
]);
return Inertia::render('folders/Index', [
'folders' => $folders,
'createUrl' => route('folders.create'),
'workspaceName' => $workspace->name,
]);
}
/**
* Show the form for creating a new folder.
*/
public function create(Request $request): Response
{
$workspace = $this->currentWorkspace($request);
$initialClientId = $request->integer('client_id', 0) ?: null;
return Inertia::render('folders/Create', [
'indexUrl' => route('folders.index'),
'storeUrl' => route('folders.store'),
'initialClientId' => $initialClientId,
'folderTypeLabels' => $this->folderTypeLabels(),
'folderStatusLabels' => $this->folderStatusLabels(),
'folderPriorityLabels' => $this->folderPriorityLabels(),
'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 folder in storage.
*/
public function store(StoreFolderRequest $request): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$data = $request->validated();
$data['workspace_id'] = $workspace->id;
$data['created_by'] = $request->user()?->id;
$data['status'] = $data['status'] ?? FolderStatus::Draft->value;
Folder::query()->create($data);
return to_route('folders.index');
}
/**
* Display the specified folder.
*/
public function show(Request $request, Folder $folder): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeFolder($workspace, $folder);
$folder->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]);
$allMedia = $folder->getMedia('documents');
$downloadedMediaIds = MediaDownload::query()
->where('user_id', $request->user()->id)
->whereIn('media_id', $allMedia->pluck('id'))
->pluck('media_id')
->all();
$messages = $folder->messages->map(function ($m) use ($folder, $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('folders.media.download', ['folder' => $folder, 'mediaId' => $media->id]),
'is_downloaded' => in_array($media->id, $downloadedMediaIds),
])
->values()
->all();
$confirmationStatus = null;
if ($m->type->value === 'confirmation') {
$confirmationStatus = $folder->refused_at ? 'refused' : ($folder->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('folders.media.download', ['folder' => $folder, 'mediaId' => $m->id]),
'is_downloaded' => in_array($m->id, $downloadedMediaIds),
])->values()->all();
return Inertia::render('folders/Show', [
'folder' => [
'id' => $folder->id,
'title' => $folder->title,
'type' => $folder->type->value,
'client_id' => $folder->client_id,
'client_name' => $folder->client->company_name,
'period_year' => $folder->period_year,
'period_month' => $folder->period_month,
'period_quarter' => $folder->period_quarter,
'due_date' => $folder->due_date?->format('Y-m-d'),
'status' => $folder->status->value,
'priority' => $folder->priority?->value,
'assigned_to' => $folder->assigned_to,
'assignee_name' => $folder->assignee?->name,
'validated_at' => $folder->validated_at?->format('Y-m-d H:i'),
'closed_at' => $folder->closed_at?->format('Y-m-d H:i'),
'notes_internal' => $folder->notes_internal,
'notes_client' => $folder->notes_client,
'created_at' => $folder->created_at?->format('Y-m-d H:i'),
],
'messages' => $messages,
'documents' => $documents,
'messagesStoreUrl' => route('folders.messages.store', $folder),
'mediaStoreUrl' => route('folders.media.store', $folder),
'messageTypeLabels' => [
'invite' => 'Invitation',
'situation' => 'Situation',
'file_request' => 'Demande de pièces',
'confirmation' => 'Demande de validation',
'text' => 'Message',
],
'indexUrl' => route('folders.index'),
'editUrl' => route('folders.edit', $folder),
'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('folders.mentions.store', $folder),
'canMention' => in_array(
$workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value,
['owner', 'manager']
),
]);
}
/**
* Show the form for editing the specified folder.
*/
public function edit(Request $request, Folder $folder): Response
{
$workspace = $this->currentWorkspace($request);
$this->authorizeFolder($workspace, $folder);
return Inertia::render('folders/Edit', [
'folder' => [
'id' => $folder->id,
'title' => $folder->title,
'type' => $folder->type->value,
'client_id' => $folder->client_id,
'period_year' => $folder->period_year,
'period_month' => $folder->period_month,
'period_quarter' => $folder->period_quarter,
'due_date' => $folder->due_date?->format('Y-m-d'),
'status' => $folder->status->value,
'priority' => $folder->priority?->value,
'assigned_to' => $folder->assigned_to,
'notes_internal' => $folder->notes_internal,
'notes_client' => $folder->notes_client,
'created_at' => $folder->created_at?->format('Y-m-d H:i'),
],
'indexUrl' => route('folders.index'),
'updateUrl' => route('folders.update', $folder),
'folderTypeLabels' => $this->folderTypeLabels(),
'folderStatusLabels' => $this->folderStatusLabels(),
'folderPriorityLabels' => $this->folderPriorityLabels(),
'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 folder in storage.
*/
public function update(UpdateFolderRequest $request, Folder $folder): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeFolder($workspace, $folder);
$folder->update($request->validated());
return to_route('folders.index');
}
/**
* Remove the specified folder from storage.
*/
public function destroy(Request $request, Folder $folder): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
$this->authorizeFolder($workspace, $folder);
$folder->delete();
return to_route('folders.index');
}
protected function authorizeFolder(Workspace $workspace, Folder $folder): void
{
if ($folder->workspace_id !== $workspace->id) {
abort(404);
}
}
}

View File

@@ -1,149 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Enums\ActorType;
use App\Enums\FolderStatus;
use App\Enums\MessageType;
use App\Http\Requests\StoreFolderMessageRequest;
use App\Mail\FolderConfirmationMail;
use App\Mail\FolderFileRequestMail;
use App\Mail\FolderInviteMail;
use App\Mail\FolderSituationMail;
use App\Mail\FolderTextMessageMail;
use App\Models\Folder;
use App\Models\FolderInvitation;
use App\Models\Message;
use App\Models\Workspace;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class FolderMessageController extends Controller
{
protected function currentWorkspace(Request $request): Workspace
{
$workspaceId = $request->session()->get('current_workspace_id');
return Workspace::query()->findOrFail($workspaceId);
}
/**
* Store a newly created message.
*/
public function store(StoreFolderMessageRequest $request, Folder $folder): RedirectResponse
{
$workspace = $this->currentWorkspace($request);
if ($folder->workspace_id !== $workspace->id) {
abort(404);
}
$user = $request->user();
$type = MessageType::fromValue($request->input('type'));
$body = $request->input('body');
$invitation = $type->is(MessageType::Invite)
? $this->createInvitation($folder)
: $this->getOrCreateInvitation($folder);
$metadata = ['invitation_id' => $invitation->id];
$message = $folder->messages()->create([
'type' => $type,
'body' => $body,
'sent_by_type' => ActorType::User,
'sent_by_id' => $user->id,
'metadata' => $metadata,
]);
$mediaIds = [];
if ($request->hasFile('files')) {
foreach ($request->file('files') as $file) {
$media = $folder->addMedia($file)
->withCustomProperties([
'message_id' => $message->id,
'uploaded_by_type' => ActorType::User,
'uploaded_by_id' => $user->id,
])
->toMediaCollection('documents');
$mediaIds[] = $media->id;
}
$message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]);
}
$this->updateFolderStatusAndConfirmation($folder, $type, $mediaIds);
$emailSent = $this->sendEmailForMessage($folder, $invitation, $message, $body, $type);
$flashMessage = $emailSent
? 'Message envoyé.'
: 'Message enregistré, mais l\'email du client n\'est pas configuré.';
return back()->with('flash', ['type' => 'success', 'message' => $flashMessage]);
}
protected function createInvitation(Folder $folder): FolderInvitation
{
$folder->load('client.primaryContact');
return $folder->invitations()->create([
'email' => $folder->client->primary_contact_email,
'expires_at' => Carbon::now()->addDays(7),
]);
}
protected function getOrCreateInvitation(Folder $folder): FolderInvitation
{
$invitation = $folder->invitations()
->where('expires_at', '>', now())
->latest()
->first();
if ($invitation) {
return $invitation;
}
return $this->createInvitation($folder);
}
/**
* @param array<int> $mediaIds
*/
protected function updateFolderStatusAndConfirmation(Folder $folder, MessageType $type, array $mediaIds): void
{
match ($type->value) {
'invite' => $folder->update(['status' => FolderStatus::WaitingDocuments]),
'situation', 'file_request' => $folder->update(['status' => FolderStatus::AdditionalDocumentsRequested]),
'confirmation' => $folder->update([
'status' => FolderStatus::WaitingClientValidation,
'confirmation_requested_at' => now(),
'confirmation_media_id' => $mediaIds[0] ?? null,
]),
default => null,
};
}
protected function sendEmailForMessage(Folder $folder, FolderInvitation $invitation, Message $message, string $body, MessageType $type): bool
{
$folder->load('client.primaryContact');
$clientEmail = $folder->client->primary_contact_email;
if (empty($clientEmail)) {
\Illuminate\Support\Facades\Log::warning("No primary contact email for client #{$folder->client_id}, skipping email.");
return false;
}
match ($type->value) {
'invite' => Mail::to($clientEmail)->send(new FolderInviteMail($folder, $invitation)),
'situation' => Mail::to($clientEmail)->send(new FolderSituationMail($folder, $invitation, $body)),
'file_request' => Mail::to($clientEmail)->send(new FolderFileRequestMail($folder, $invitation, $body)),
'confirmation' => Mail::to($clientEmail)->send(new FolderConfirmationMail($folder, $invitation, $body)),
'text' => Mail::to($clientEmail)->send(new FolderTextMessageMail($folder, $body, $invitation->token)),
default => null,
};
return true;
}
}

View File

@@ -81,20 +81,19 @@ class WorkspaceController extends Controller
$workspace->load('users');
$clientsCount = $workspace->clients()->count();
$foldersCount = $workspace->folders()->count();
$foldersByStatus = $workspace->folders()
$declarationsCount = $workspace->declarations()->count();
$declarationsByStatus = $workspace->declarations()
->selectRaw('status, count(*) as count')
->groupBy('status')
->pluck('count', 'status')
->all();
$foldersThisMonth = $workspace->folders()
$declarationsThisMonth = $workspace->declarations()
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
$foldersNeedingAttention = $workspace->folders()
$declarationsNeedingAttention = $workspace->declarations()
->whereIn('status', [
\App\Enums\FolderStatus::WaitingDocuments,
\App\Enums\FolderStatus::WaitingClientValidation,
\App\Enums\DeclarationStatus::EnAttenteClient,
])
->count();
@@ -112,10 +111,10 @@ class WorkspaceController extends Controller
],
'stats' => [
'clients' => $clientsCount,
'folders' => $foldersCount,
'folders_by_status' => $foldersByStatus,
'folders_this_month' => $foldersThisMonth,
'folders_needing_attention' => $foldersNeedingAttention,
'declarations' => $declarationsCount,
'declarations_by_status' => $declarationsByStatus,
'declarations_this_month' => $declarationsThisMonth,
'declarations_needing_attention' => $declarationsNeedingAttention,
],
'indexUrl' => route('workspaces.index'),
'editUrl' => route('workspaces.edit', $workspace),

View File

@@ -2,12 +2,12 @@
namespace App\Http\Middleware;
use App\Models\FolderInvitation;
use App\Models\DeclarationInvitation;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ValidateFolderInvitation
class ValidateClientPortalToken
{
/**
* Handle an incoming request.
@@ -18,16 +18,16 @@ class ValidateFolderInvitation
{
$token = $request->route('token');
$invitation = FolderInvitation::query()
$invitation = DeclarationInvitation::query()
->where('token', $token)
->with(['folder.client', 'folder.assignee', 'folder.creator'])
->with(['declaration.client', 'declaration.assignee', 'declaration.creator'])
->first();
if (! $invitation || ! $invitation->isValid()) {
abort(404, 'Lien invalide ou expiré.');
}
$request->attributes->set('folder_invitation', $invitation);
$request->attributes->set('declaration_invitation', $invitation);
return $next($request);
}

View File

@@ -5,7 +5,7 @@ namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreFolderMentionRequest extends FormRequest
class StoreDeclarationMentionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.

View File

@@ -6,7 +6,7 @@ use App\Enums\MessageType;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Foundation\Http\FormRequest;
class StoreFolderMessageRequest extends FormRequest
class StoreDeclarationMessageRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.

View File

@@ -2,15 +2,15 @@
namespace App\Http\Requests;
use App\Enums\FolderPriority;
use App\Enums\FolderStatus;
use App\Enums\FolderType;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreFolderRequest extends FormRequest
class StoreDeclarationRequest extends FormRequest
{
/**
* Prepare the data for validation.
@@ -59,13 +59,13 @@ class StoreFolderRequest extends FormRequest
Rule::exists('clients', 'id')->where('workspace_id', $workspaceId),
],
'title' => ['required', 'string', 'max:255'],
'type' => ['required', new EnumValue(FolderType::class)],
'type' => ['required', new EnumValue(DeclarationType::class)],
'period_year' => ['required', 'integer', 'min:2000', 'max:2100'],
'period_month' => ['nullable', 'integer', 'min:1', 'max:12'],
'period_quarter' => ['nullable', 'integer', 'min:1', 'max:4'],
'due_date' => ['nullable', 'date'],
'status' => ['nullable', Rule::in(FolderStatus::getValues())],
'priority' => ['nullable', Rule::in(FolderPriority::getValues())],
'status' => ['nullable', Rule::in(DeclarationStatus::getValues())],
'priority' => ['nullable', Rule::in(DeclarationPriority::getValues())],
'assigned_to' => [
'nullable',
'integer',

View File

@@ -31,7 +31,7 @@ class StoreWorkspaceRequest extends FormRequest
'user_ids' => ['array'],
'user_ids.*' => ['integer', 'exists:users,id'],
'user_roles' => ['nullable', 'array'],
'user_roles.*' => ['string', 'in:' . implode(',', WorkspaceUserRole::getValues())],
'user_roles.*' => ['string', 'in:'.implode(',', WorkspaceUserRole::getValues())],
];
}
}

View File

@@ -2,15 +2,15 @@
namespace App\Http\Requests;
use App\Enums\FolderPriority;
use App\Enums\FolderStatus;
use App\Enums\FolderType;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateFolderRequest extends FormRequest
class UpdateDeclarationRequest extends FormRequest
{
/**
* Prepare the data for validation.
@@ -59,13 +59,13 @@ class UpdateFolderRequest extends FormRequest
Rule::exists('clients', 'id')->where('workspace_id', $workspaceId),
],
'title' => ['required', 'string', 'max:255'],
'type' => ['required', new EnumValue(FolderType::class)],
'type' => ['required', new EnumValue(DeclarationType::class)],
'period_year' => ['required', 'integer', 'min:2000', 'max:2100'],
'period_month' => ['nullable', 'integer', 'min:1', 'max:12'],
'period_quarter' => ['nullable', 'integer', 'min:1', 'max:4'],
'due_date' => ['nullable', 'date'],
'status' => ['nullable', Rule::in(FolderStatus::getValues())],
'priority' => ['nullable', Rule::in(FolderPriority::getValues())],
'status' => ['nullable', Rule::in(DeclarationStatus::getValues())],
'priority' => ['nullable', Rule::in(DeclarationPriority::getValues())],
'assigned_to' => [
'nullable',
'integer',

View File

@@ -34,7 +34,7 @@ class UpdateWorkspaceRequest extends FormRequest
'user_ids' => ['array'],
'user_ids.*' => ['integer', 'exists:users,id'],
'user_roles' => ['nullable', 'array'],
'user_roles.*' => ['string', 'in:' . implode(',', WorkspaceUserRole::getValues())],
'user_roles.*' => ['string', 'in:'.implode(',', WorkspaceUserRole::getValues())],
];
}
}

View File

@@ -2,15 +2,15 @@
namespace App\Mail;
use App\Models\Folder;
use App\Models\FolderInvitation;
use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FolderConfirmationMail extends Mailable
class DeclarationConfirmationMail extends Mailable
{
use Queueable, SerializesModels;
@@ -18,8 +18,8 @@ class FolderConfirmationMail extends Mailable
* Create a new message instance.
*/
public function __construct(
public Folder $folder,
public FolderInvitation $invitation,
public Declaration $declaration,
public DeclarationInvitation $invitation,
public string $body
) {}
@@ -29,7 +29,7 @@ class FolderConfirmationMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Demande de validation - '.$this->folder->title,
subject: 'Demande de validation - '.$this->declaration->title,
);
}
@@ -39,9 +39,9 @@ class FolderConfirmationMail extends Mailable
public function content(): Content
{
return new Content(
markdown: 'emails.folder-confirmation',
markdown: 'emails.declaration-confirmation',
with: [
'folderTitle' => $this->folder->title,
'declarationTitle' => $this->declaration->title,
'body' => $this->body,
'confirmUrl' => route('client.confirm', ['token' => $this->invitation->token]),
'refuseUrl' => route('client.refuse', ['token' => $this->invitation->token]),

View File

@@ -2,15 +2,15 @@
namespace App\Mail;
use App\Models\Folder;
use App\Models\FolderInvitation;
use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FolderFileRequestMail extends Mailable
class DeclarationFileRequestMail extends Mailable
{
use Queueable, SerializesModels;
@@ -18,8 +18,8 @@ class FolderFileRequestMail extends Mailable
* Create a new message instance.
*/
public function __construct(
public Folder $folder,
public FolderInvitation $invitation,
public Declaration $declaration,
public DeclarationInvitation $invitation,
public string $body
) {}
@@ -29,7 +29,7 @@ class FolderFileRequestMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Documents complémentaires demandés - '.$this->folder->title,
subject: 'Documents complémentaires demandés - '.$this->declaration->title,
);
}
@@ -39,9 +39,9 @@ class FolderFileRequestMail extends Mailable
public function content(): Content
{
return new Content(
markdown: 'emails.folder-file-request',
markdown: 'emails.declaration-file-request',
with: [
'folderTitle' => $this->folder->title,
'declarationTitle' => $this->declaration->title,
'body' => $this->body,
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),

View File

@@ -2,15 +2,15 @@
namespace App\Mail;
use App\Models\Folder;
use App\Models\FolderInvitation;
use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FolderInviteMail extends Mailable
class DeclarationInviteMail extends Mailable
{
use Queueable, SerializesModels;
@@ -18,8 +18,8 @@ class FolderInviteMail extends Mailable
* Create a new message instance.
*/
public function __construct(
public Folder $folder,
public FolderInvitation $invitation
public Declaration $declaration,
public DeclarationInvitation $invitation
) {}
/**
@@ -28,7 +28,7 @@ class FolderInviteMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Dépôt de documents - '.$this->folder->title,
subject: 'Dépôt de documents - '.$this->declaration->title,
);
}
@@ -38,9 +38,9 @@ class FolderInviteMail extends Mailable
public function content(): Content
{
return new Content(
markdown: 'emails.folder-invite',
markdown: 'emails.declaration-invite',
with: [
'folderTitle' => $this->folder->title,
'declarationTitle' => $this->declaration->title,
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),
]

View File

@@ -2,15 +2,15 @@
namespace App\Mail;
use App\Models\Folder;
use App\Models\FolderInvitation;
use App\Models\Declaration;
use App\Models\DeclarationInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FolderSituationMail extends Mailable
class DeclarationSituationMail extends Mailable
{
use Queueable, SerializesModels;
@@ -18,8 +18,8 @@ class FolderSituationMail extends Mailable
* Create a new message instance.
*/
public function __construct(
public Folder $folder,
public FolderInvitation $invitation,
public Declaration $declaration,
public DeclarationInvitation $invitation,
public string $body
) {}
@@ -29,7 +29,7 @@ class FolderSituationMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Situation mise à jour - '.$this->folder->title,
subject: 'Situation mise à jour - '.$this->declaration->title,
);
}
@@ -39,9 +39,9 @@ class FolderSituationMail extends Mailable
public function content(): Content
{
return new Content(
markdown: 'emails.folder-situation',
markdown: 'emails.declaration-situation',
with: [
'folderTitle' => $this->folder->title,
'declarationTitle' => $this->declaration->title,
'body' => $this->body,
'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]),
'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'),

View File

@@ -2,24 +2,24 @@
namespace App\Mail;
use App\Models\Folder;
use App\Models\Declaration;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class FolderTextMessageMail extends Mailable
class DeclarationTextMessageMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*
* @param string|null $token When set, recipient is client (use token-based URL). When null, recipient is comptable (use folders.show).
* @param string|null $token When set, recipient is client (use token-based URL). When null, recipient is comptable (use declarations.show).
*/
public function __construct(
public Folder $folder,
public Declaration $declaration,
public string $body,
public ?string $token = null
) {}
@@ -30,7 +30,7 @@ class FolderTextMessageMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
subject: 'Nouveau message - '.$this->folder->title,
subject: 'Nouveau message - '.$this->declaration->title,
);
}
@@ -41,12 +41,12 @@ class FolderTextMessageMail extends Mailable
{
$messagesUrl = $this->token
? route('client.upload', ['token' => $this->token])
: route('folders.show', ['folder' => $this->folder]).'?tab=messages';
: route('declarations.show', ['declaration' => $this->declaration]).'?tab=messages';
return new Content(
markdown: 'emails.folder-text-message',
markdown: 'emails.declaration-text-message',
with: [
'folderTitle' => $this->folder->title,
'declarationTitle' => $this->declaration->title,
'body' => $this->body,
'messagesUrl' => $messagesUrl,
]

View File

@@ -16,7 +16,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
class Client extends Model
{
/** @use HasFactory<\Database\Factories\ClientFactory> */
use HasFactory, SoftDeletes, LogsActivity;
use HasFactory, LogsActivity, SoftDeletes;
/**
* The attributes that are mass assignable.
@@ -101,13 +101,13 @@ class Client extends Model
}
/**
* Get the folders for the client.
* Get the declarations for the client.
*
* @return HasMany<Folder>
* @return HasMany<Declaration>
*/
public function folders(): HasMany
public function declarations(): HasMany
{
return $this->hasMany(Folder::class);
return $this->hasMany(Declaration::class);
}
public function getActivitylogOptions(): LogOptions

View File

@@ -2,9 +2,10 @@
namespace App\Models;
use App\Enums\FolderPriority;
use App\Enums\FolderStatus;
use App\Enums\FolderType;
use App\Enums\DeclarationPriority;
use App\Enums\DeclarationStatus;
use App\Enums\DeclarationType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -15,11 +16,13 @@ use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Folder extends Model implements HasMedia
class Declaration extends Model implements HasMedia
{
/** @use HasFactory<\Database\Factories\FolderFactory> */
/** @use HasFactory<\Database\Factories\DeclarationFactory> */
use HasFactory, InteractsWithMedia, LogsActivity, SoftDeletes;
protected $table = 'declarations';
/**
* The attributes that are mass assignable.
*
@@ -49,6 +52,7 @@ class Folder extends Model implements HasMedia
'refusal_reason',
'notes_internal',
'notes_client',
'archived_at',
'created_at',
];
@@ -60,13 +64,14 @@ class Folder extends Model implements HasMedia
protected function casts(): array
{
return [
'type' => FolderType::class,
'status' => FolderStatus::class,
'priority' => FolderPriority::class,
'type' => DeclarationType::class,
'status' => DeclarationStatus::class,
'priority' => DeclarationPriority::class,
'validated_at' => 'datetime',
'closed_at' => 'datetime',
'confirmation_requested_at' => 'datetime',
'refused_at' => 'datetime',
'archived_at' => 'datetime',
'due_date' => 'date',
];
}
@@ -80,7 +85,7 @@ class Folder extends Model implements HasMedia
}
/**
* Get the workspace that owns the folder.
* Get the workspace that owns the declaration.
*
* @return BelongsTo<Workspace, $this>
*/
@@ -90,7 +95,7 @@ class Folder extends Model implements HasMedia
}
/**
* Get the client that owns the folder.
* Get the client that owns the declaration.
*
* @return BelongsTo<Client, $this>
*/
@@ -100,7 +105,7 @@ class Folder extends Model implements HasMedia
}
/**
* Get the user who created the folder.
* Get the user who created the declaration.
*
* @return BelongsTo<User, $this>
*/
@@ -110,7 +115,7 @@ class Folder extends Model implements HasMedia
}
/**
* Get the user assigned to the folder.
* Get the user assigned to the declaration.
*
* @return BelongsTo<User, $this>
*/
@@ -120,7 +125,7 @@ class Folder extends Model implements HasMedia
}
/**
* Get the messages for the folder.
* Get the messages for the declaration.
*
* @return HasMany<Message>
*/
@@ -130,13 +135,29 @@ class Folder extends Model implements HasMedia
}
/**
* Get the invitations for the folder.
* Get the invitations for the declaration.
*
* @return HasMany<FolderInvitation>
* @return HasMany<DeclarationInvitation>
*/
public function invitations(): HasMany
{
return $this->hasMany(FolderInvitation::class);
return $this->hasMany(DeclarationInvitation::class);
}
/**
* Scope a query to only include active (non-archived) declarations.
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
/**
* Scope a query to only include archived declarations.
*/
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
public function getActivitylogOptions(): LogOptions

View File

@@ -6,15 +6,17 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class FolderInvitation extends Model
class DeclarationInvitation extends Model
{
protected $table = 'declaration_invitations';
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'folder_id',
'declaration_id',
'token',
'email',
'expires_at',
@@ -41,7 +43,7 @@ class FolderInvitation extends Model
{
parent::boot();
static::creating(function (FolderInvitation $invitation) {
static::creating(function (DeclarationInvitation $invitation) {
if (empty($invitation->token)) {
$invitation->token = Str::uuid()->toString();
}
@@ -49,13 +51,13 @@ class FolderInvitation extends Model
}
/**
* Get the folder that owns the invitation.
* Get the declaration that owns the invitation.
*
* @return BelongsTo<Folder, $this>
* @return BelongsTo<Declaration, $this>
*/
public function folder(): BelongsTo
public function declaration(): BelongsTo
{
return $this->belongsTo(Folder::class);
return $this->belongsTo(Declaration::class);
}
/**

View File

@@ -15,7 +15,7 @@ class Message extends Model
* @var list<string>
*/
protected $fillable = [
'folder_id',
'declaration_id',
'type',
'body',
'sent_by_type',
@@ -38,13 +38,13 @@ class Message extends Model
}
/**
* Get the folder that owns the message.
* Get the declaration that owns the message.
*
* @return BelongsTo<Folder, $this>
* @return BelongsTo<Declaration, $this>
*/
public function folder(): BelongsTo
public function declaration(): BelongsTo
{
return $this->belongsTo(Folder::class);
return $this->belongsTo(Declaration::class);
}
/**

View File

@@ -16,7 +16,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, SoftDeletes, TwoFactorAuthenticatable, LogsActivity;
use HasFactory, LogsActivity, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.

View File

@@ -14,7 +14,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
class Workspace extends Model
{
/** @use HasFactory<\Database\Factories\WorkspaceFactory> */
use HasFactory, SoftDeletes, LogsActivity;
use HasFactory, LogsActivity, SoftDeletes;
/**
* The attributes that are mass assignable.
@@ -57,13 +57,13 @@ class Workspace extends Model
}
/**
* Get the folders for the workspace.
* Get the declarations for the workspace.
*
* @return HasMany<Folder>
* @return HasMany<Declaration>
*/
public function folders(): HasMany
public function declarations(): HasMany
{
return $this->hasMany(Folder::class);
return $this->hasMany(Declaration::class);
}
/**

View File

@@ -21,6 +21,7 @@ class WorkspaceUser extends Pivot
*/
protected $fillable = [
'role',
'permissions',
];
/**
@@ -32,6 +33,7 @@ class WorkspaceUser extends Pivot
{
return [
'role' => WorkspaceUserRole::class,
'permissions' => 'array',
];
}
}

View File

@@ -2,19 +2,19 @@
namespace App\Notifications;
use App\Models\Folder;
use App\Models\Declaration;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class FolderMentionNotification extends Notification implements ShouldQueue
class DeclarationMentionNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Folder $folder,
public Declaration $declaration,
public User $mentionedBy,
public string $message,
) {}
@@ -33,24 +33,24 @@ class FolderMentionNotification extends Notification implements ShouldQueue
public function toDatabase(object $notifiable): array
{
return [
'folder_id' => $this->folder->id,
'folder_title' => $this->folder->title,
'declaration_id' => $this->declaration->id,
'declaration_title' => $this->declaration->title,
'mentioned_by_id' => $this->mentionedBy->id,
'mentioned_by_name' => $this->mentionedBy->name,
'message' => $this->message,
'url' => route('folders.show', $this->folder),
'url' => route('declarations.show', $this->declaration),
];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Vous avez été mentionné - '.$this->folder->title)
->markdown('emails.folder-mention', [
'folderTitle' => $this->folder->title,
->subject('Vous avez été mentionné - '.$this->declaration->title)
->markdown('emails.declaration-mention', [
'declarationTitle' => $this->declaration->title,
'mentionedByName' => $this->mentionedBy->name,
'message' => $this->message,
'url' => route('folders.show', $this->folder),
'url' => route('declarations.show', $this->declaration),
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Observers;
use App\Enums\DeclarationStatus;
use App\Models\Declaration;
use Illuminate\Validation\ValidationException;
class DeclarationObserver
{
/**
* Handle the Declaration "updating" event.
*
* Validates status transitions and auto-archives when status becomes "ferme".
*/
public function updating(Declaration $declaration): void
{
if (! $declaration->isDirty('status')) {
return;
}
$oldStatus = $declaration->getOriginal('status');
$newStatus = $declaration->status;
// Handle both string and enum values
$oldValue = $oldStatus instanceof DeclarationStatus ? $oldStatus->value : (string) $oldStatus;
$newValue = $newStatus instanceof DeclarationStatus ? $newStatus->value : (string) $newStatus;
$allowed = DeclarationStatus::allowedTransitions()[$oldValue] ?? [];
if (! in_array($newValue, $allowed)) {
throw ValidationException::withMessages([
'status' => "Invalid status transition from '{$oldValue}' to '{$newValue}'.",
]);
}
// Auto-archive when status becomes "ferme"
if ($newValue === DeclarationStatus::Ferme) {
$declaration->archived_at = now();
}
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Declaration;
use App\Observers\DeclarationObserver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@@ -24,6 +26,8 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureDefaults();
Declaration::observe(DeclarationObserver::class);
}
/**