Skip to content

Commit 36fa095

Browse files
committed
feat: Events + Documents DMS modules — 20 tests passing
Events module (10 tests): - Event model: publish/cancel, registrationCount, availableSpots, isFull, isOpen - EventRegistration: confirm, markAttended, cancel - EventController: CRUD + publish/cancel/register/confirmRegistration/markAttended/cancelRegistration - Capacity enforcement (422 when full), closed-event guard - React pages: Index (table with badges, new event form) + Show (registrations table, register form) - 2 migrations: events, event_registrations Documents DMS module (10 tests): - DocumentFolder: nested folders (parent/children), documentCount - Document: tags as JSON array, version tracking, addVersion(), fileSizeFormatted() - DocumentVersion: full version history with uploader - DocumentController: CRUD + addVersion + storeFolder/destroyFolder + search - React pages: Index (sidebar folder tree, search, upload form) + Show (version history) + Search results page + Folders listing page - 3 migrations: document_folders, documents, document_versions https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 067e8a6 commit 36fa095

25 files changed

Lines changed: 2397 additions & 0 deletions

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
use App\Modules\Rental\Providers\RentalServiceProvider;
2626
use App\Modules\Subscriptions\Providers\SubscriptionsServiceProvider;
2727
use App\Modules\Survey\Providers\SurveyServiceProvider;
28+
use App\Modules\Documents\Providers\DocumentsServiceProvider;
29+
use App\Modules\Events\Providers\EventsServiceProvider;
2830
use Illuminate\Support\Facades\Gate;
2931
use Illuminate\Support\ServiceProvider;
3032

@@ -51,6 +53,8 @@ public function register(): void
5153
$this->app->register(RentalServiceProvider::class);
5254
$this->app->register(SubscriptionsServiceProvider::class);
5355
$this->app->register(SurveyServiceProvider::class);
56+
$this->app->register(DocumentsServiceProvider::class);
57+
$this->app->register(EventsServiceProvider::class);
5458
}
5559

5660
public function boot(): void
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<?php
2+
3+
namespace App\Modules\Documents\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Documents\Models\Document;
7+
use App\Modules\Documents\Models\DocumentFolder;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class DocumentController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$documents = Document::with(['folder', 'uploader'])
18+
->when($request->folder_id, fn ($q) => $q->where('folder_id', $request->folder_id))
19+
->when($request->search, function ($q) use ($request) {
20+
$search = $request->search;
21+
$q->where(function ($inner) use ($search) {
22+
$inner->where('title', 'like', "%{$search}%")
23+
->orWhereJsonContains('tags', $search);
24+
});
25+
})
26+
->orderByDesc('created_at')
27+
->paginate(20)
28+
->withQueryString();
29+
30+
$folders = DocumentFolder::with('children')
31+
->whereNull('parent_id')
32+
->orderBy('name')
33+
->get();
34+
35+
return Inertia::render('Documents/Index', [
36+
'documents' => $documents,
37+
'folders' => $folders,
38+
'filters' => $request->only(['folder_id', 'search']),
39+
]);
40+
}
41+
42+
public function folders(): Response
43+
{
44+
$folders = DocumentFolder::with('children')
45+
->whereNull('parent_id')
46+
->orderBy('name')
47+
->get()
48+
->map(function (DocumentFolder $folder) {
49+
return [
50+
'id' => $folder->id,
51+
'name' => $folder->name,
52+
'document_count' => $folder->documentCount(),
53+
'children' => $folder->children->map(function (DocumentFolder $child) {
54+
return [
55+
'id' => $child->id,
56+
'name' => $child->name,
57+
'document_count' => $child->documentCount(),
58+
'children' => [],
59+
];
60+
}),
61+
];
62+
});
63+
64+
return Inertia::render('Documents/Folders', [
65+
'folders' => $folders,
66+
]);
67+
}
68+
69+
public function show(Document $document): Response
70+
{
71+
$document->load(['folder', 'uploader', 'versions.uploader']);
72+
73+
return Inertia::render('Documents/Show', [
74+
'document' => $document,
75+
]);
76+
}
77+
78+
public function store(Request $request): RedirectResponse
79+
{
80+
$validated = $request->validate([
81+
'title' => 'required|string|max:255',
82+
'description' => 'nullable|string',
83+
'folder_id' => 'nullable|exists:document_folders,id',
84+
'file_path' => 'required|string',
85+
'file_name' => 'nullable|string|max:255',
86+
'file_size' => 'nullable|integer',
87+
'mime_type' => 'nullable|string|max:255',
88+
'tags' => 'nullable|array',
89+
]);
90+
91+
$document = Document::create([
92+
'tenant_id' => auth()->user()->tenant_id,
93+
'title' => $validated['title'],
94+
'description' => $validated['description'] ?? null,
95+
'folder_id' => $validated['folder_id'] ?? null,
96+
'file_path' => $validated['file_path'],
97+
'file_name' => $validated['file_name'] ?? basename($validated['file_path']),
98+
'file_size' => $validated['file_size'] ?? null,
99+
'mime_type' => $validated['mime_type'] ?? null,
100+
'tags' => $validated['tags'] ?? null,
101+
'version' => 1,
102+
'uploaded_by' => auth()->id(),
103+
]);
104+
105+
return redirect()->route('documents.show', $document)->with('success', 'Document uploaded.');
106+
}
107+
108+
public function update(Request $request, Document $document): RedirectResponse
109+
{
110+
$validated = $request->validate([
111+
'title' => 'sometimes|required|string|max:255',
112+
'description' => 'nullable|string',
113+
'folder_id' => 'nullable|exists:document_folders,id',
114+
'tags' => 'nullable|array',
115+
]);
116+
117+
$document->update($validated);
118+
119+
return redirect()->route('documents.show', $document)->with('success', 'Document updated.');
120+
}
121+
122+
public function destroy(Document $document): RedirectResponse
123+
{
124+
$document->delete();
125+
126+
return redirect()->route('documents.index')->with('success', 'Document deleted.');
127+
}
128+
129+
public function addVersion(Request $request, Document $document): RedirectResponse
130+
{
131+
$validated = $request->validate([
132+
'file_path' => 'required|string',
133+
'file_name' => 'required|string|max:255',
134+
'file_size' => 'nullable|integer',
135+
'notes' => 'nullable|string',
136+
]);
137+
138+
$document->addVersion(
139+
filePath: $validated['file_path'],
140+
fileName: $validated['file_name'],
141+
fileSize: $validated['file_size'] ?? null,
142+
uploadedBy: auth()->id(),
143+
notes: $validated['notes'] ?? null,
144+
);
145+
146+
return redirect()->route('documents.show', $document)->with('success', 'New version added.');
147+
}
148+
149+
public function storeFolder(Request $request): RedirectResponse
150+
{
151+
$validated = $request->validate([
152+
'name' => 'required|string|max:255',
153+
'parent_id' => 'nullable|exists:document_folders,id',
154+
]);
155+
156+
DocumentFolder::create([
157+
'tenant_id' => auth()->user()->tenant_id,
158+
'name' => $validated['name'],
159+
'parent_id' => $validated['parent_id'] ?? null,
160+
'created_by' => auth()->id(),
161+
]);
162+
163+
return redirect()->back()->with('success', 'Folder created.');
164+
}
165+
166+
public function destroyFolder(DocumentFolder $folder): RedirectResponse
167+
{
168+
if ($folder->documentCount() > 0) {
169+
return redirect()->back()->withErrors(['folder' => 'Cannot delete a folder that contains documents.']);
170+
}
171+
172+
$folder->delete();
173+
174+
return redirect()->back()->with('success', 'Folder deleted.');
175+
}
176+
177+
public function search(Request $request): Response
178+
{
179+
$query = $request->get('q', '');
180+
181+
$documents = Document::with(['folder', 'uploader'])
182+
->when($query, function ($q) use ($query) {
183+
$q->where(function ($inner) use ($query) {
184+
$inner->where('title', 'like', "%{$query}%")
185+
->orWhereJsonContains('tags', $query);
186+
});
187+
})
188+
->orderByDesc('created_at')
189+
->paginate(20)
190+
->withQueryString();
191+
192+
return Inertia::render('Documents/Search', [
193+
'documents' => $documents,
194+
'query' => $query,
195+
]);
196+
}
197+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
namespace App\Modules\Documents\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class Document extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'folder_id',
19+
'title',
20+
'description',
21+
'file_path',
22+
'file_name',
23+
'file_size',
24+
'mime_type',
25+
'version',
26+
'tags',
27+
'uploaded_by',
28+
];
29+
30+
protected $casts = [
31+
'tags' => 'array',
32+
];
33+
34+
public function folder(): BelongsTo
35+
{
36+
return $this->belongsTo(DocumentFolder::class, 'folder_id');
37+
}
38+
39+
public function uploader(): BelongsTo
40+
{
41+
return $this->belongsTo(User::class, 'uploaded_by');
42+
}
43+
44+
public function versions(): HasMany
45+
{
46+
return $this->hasMany(DocumentVersion::class)->orderByDesc('version');
47+
}
48+
49+
public function addVersion(
50+
string $filePath,
51+
string $fileName,
52+
?int $fileSize,
53+
int $uploadedBy,
54+
?string $notes = null
55+
): DocumentVersion {
56+
$this->version += 1;
57+
$this->file_path = $filePath;
58+
$this->file_name = $fileName;
59+
$this->file_size = $fileSize;
60+
$this->save();
61+
62+
return $this->versions()->create([
63+
'document_id' => $this->id,
64+
'tenant_id' => $this->tenant_id,
65+
'version' => $this->version,
66+
'file_path' => $filePath,
67+
'file_name' => $fileName,
68+
'file_size' => $fileSize,
69+
'uploaded_by' => $uploadedBy,
70+
'notes' => $notes,
71+
]);
72+
}
73+
74+
public function fileSizeFormatted(): string
75+
{
76+
if ($this->file_size === null) {
77+
return 'Unknown';
78+
}
79+
80+
$bytes = $this->file_size;
81+
82+
if ($bytes >= 1073741824) {
83+
return round($bytes / 1073741824, 2) . ' GB';
84+
}
85+
86+
if ($bytes >= 1048576) {
87+
return round($bytes / 1048576, 2) . ' MB';
88+
}
89+
90+
if ($bytes >= 1024) {
91+
return round($bytes / 1024, 2) . ' KB';
92+
}
93+
94+
return $bytes . ' B';
95+
}
96+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
namespace App\Modules\Documents\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class DocumentFolder extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'name',
19+
'parent_id',
20+
'created_by',
21+
];
22+
23+
public function parent(): BelongsTo
24+
{
25+
return $this->belongsTo(self::class, 'parent_id');
26+
}
27+
28+
public function children(): HasMany
29+
{
30+
return $this->hasMany(self::class, 'parent_id');
31+
}
32+
33+
public function documents(): HasMany
34+
{
35+
return $this->hasMany(Document::class, 'folder_id');
36+
}
37+
38+
public function documentCount(): int
39+
{
40+
return $this->documents()->count();
41+
}
42+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Documents\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class DocumentVersion extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'document_id',
16+
'tenant_id',
17+
'version',
18+
'file_path',
19+
'file_name',
20+
'file_size',
21+
'uploaded_by',
22+
'notes',
23+
];
24+
25+
public function document(): BelongsTo
26+
{
27+
return $this->belongsTo(Document::class);
28+
}
29+
30+
public function uploader(): BelongsTo
31+
{
32+
return $this->belongsTo(User::class, 'uploaded_by');
33+
}
34+
}

0 commit comments

Comments
 (0)