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

@@ -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),