Skip to content

Commit 1b36957

Browse files
committed
feat: Knowledge Base + Planning/Shifts modules — 20 tests passing
Knowledge Base (10 tests): - KbCategory: nested categories (parent/children), articleCount, slug generation - KbArticle: publish/archive, incrementViews, generateSlug, tags as JSON array - KnowledgeBaseController: CRUD + publish/archive/storeCategory/search - Full-text search across title + content (published articles only) - React pages: Index (sidebar category tree, article table) + Show (detail + actions) - 2 migrations: kb_categories, kb_articles Planning / Shifts (10 tests): - Shift model: durationMinutes/Hours, confirm/complete/cancel, overlapsWithUserShifts() conflict detection - ShiftSwap model: approve() (reassigns shift to new employee) + reject() - PlanningController: CRUD + confirm/complete/cancel + requestSwap/approveSwap/rejectSwap - 422 on overlapping shift for same employee - React pages: Index (shift list + new shift form) + Show (actions + swap management) + Schedule (weekly grid view) - 2 migrations: shifts, shift_swaps https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 36fa095 commit 1b36957

22 files changed

Lines changed: 2258 additions & 0 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
use App\Modules\Survey\Providers\SurveyServiceProvider;
2828
use App\Modules\Documents\Providers\DocumentsServiceProvider;
2929
use App\Modules\Events\Providers\EventsServiceProvider;
30+
use App\Modules\KnowledgeBase\Providers\KnowledgeBaseServiceProvider;
31+
use App\Modules\Planning\Providers\PlanningServiceProvider;
3032
use Illuminate\Support\Facades\Gate;
3133
use Illuminate\Support\ServiceProvider;
3234

@@ -55,6 +57,8 @@ public function register(): void
5557
$this->app->register(SurveyServiceProvider::class);
5658
$this->app->register(DocumentsServiceProvider::class);
5759
$this->app->register(EventsServiceProvider::class);
60+
$this->app->register(KnowledgeBaseServiceProvider::class);
61+
$this->app->register(PlanningServiceProvider::class);
5862
}
5963

6064
public function boot(): void
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<?php
2+
3+
namespace App\Modules\KnowledgeBase\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\KnowledgeBase\Models\KbArticle;
7+
use App\Modules\KnowledgeBase\Models\KbCategory;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class KnowledgeBaseController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$articles = KbArticle::with(['category', 'author'])
18+
->when($request->search, function ($q, $search) {
19+
$q->where(function ($q) use ($search) {
20+
$q->where('title', 'like', "%{$search}%")
21+
->orWhere('content', 'like', "%{$search}%");
22+
});
23+
})
24+
->when($request->category_id, fn ($q) => $q->where('category_id', $request->category_id))
25+
->orderByDesc('created_at')
26+
->paginate(20)
27+
->withQueryString();
28+
29+
$categories = KbCategory::with('children')->whereNull('parent_id')->orderBy('sequence')->get();
30+
31+
return Inertia::render('KnowledgeBase/Index', [
32+
'articles' => $articles,
33+
'categories' => $categories,
34+
'filters' => $request->only(['search', 'category_id']),
35+
]);
36+
}
37+
38+
public function categories(): Response
39+
{
40+
$categories = KbCategory::with('children')
41+
->whereNull('parent_id')
42+
->orderBy('sequence')
43+
->get()
44+
->map(function (KbCategory $category) {
45+
return array_merge($category->toArray(), [
46+
'article_count' => $category->articleCount(),
47+
]);
48+
});
49+
50+
return Inertia::render('KnowledgeBase/Categories', [
51+
'categories' => $categories,
52+
]);
53+
}
54+
55+
public function show(KbArticle $article): Response
56+
{
57+
$article->incrementViews();
58+
$article->load(['category', 'author']);
59+
60+
return Inertia::render('KnowledgeBase/Show', [
61+
'article' => $article,
62+
]);
63+
}
64+
65+
public function store(Request $request): RedirectResponse
66+
{
67+
$validated = $request->validate([
68+
'title' => 'required|string|max:255',
69+
'content' => 'required|string',
70+
'category_id' => 'nullable|exists:kb_categories,id',
71+
'excerpt' => 'nullable|string',
72+
'tags' => 'nullable|string',
73+
'status' => 'nullable|in:draft,published,archived',
74+
]);
75+
76+
$article = new KbArticle();
77+
$article->tenant_id = auth()->user()->tenant_id;
78+
$article->author_id = auth()->id();
79+
$article->title = $validated['title'];
80+
$article->content = $validated['content'];
81+
$article->category_id = $validated['category_id'] ?? null;
82+
$article->excerpt = $validated['excerpt'] ?? null;
83+
$article->status = $validated['status'] ?? 'draft';
84+
85+
// Handle tags: convert comma-separated string to array
86+
if (!empty($validated['tags'])) {
87+
$article->tags = array_map('trim', explode(',', $validated['tags']));
88+
}
89+
90+
$article->slug = $article->generateSlug($validated['title']);
91+
$article->save();
92+
93+
return redirect()->route('kb.show', $article)->with('success', 'Article created.');
94+
}
95+
96+
public function update(Request $request, KbArticle $article): RedirectResponse
97+
{
98+
$validated = $request->validate([
99+
'title' => 'sometimes|required|string|max:255',
100+
'content' => 'sometimes|required|string',
101+
'category_id' => 'nullable|exists:kb_categories,id',
102+
'excerpt' => 'nullable|string',
103+
'tags' => 'nullable|string',
104+
'status' => 'nullable|in:draft,published,archived',
105+
]);
106+
107+
if (isset($validated['tags']) && is_string($validated['tags'])) {
108+
$validated['tags'] = array_map('trim', explode(',', $validated['tags']));
109+
}
110+
111+
$article->update($validated);
112+
113+
return redirect()->route('kb.show', $article)->with('success', 'Article updated.');
114+
}
115+
116+
public function destroy(KbArticle $article): RedirectResponse
117+
{
118+
$article->delete();
119+
120+
return redirect()->route('kb.index')->with('success', 'Article deleted.');
121+
}
122+
123+
public function publish(KbArticle $article): RedirectResponse
124+
{
125+
$article->publish();
126+
127+
return redirect()->back()->with('success', 'Article published.');
128+
}
129+
130+
public function archive(KbArticle $article): RedirectResponse
131+
{
132+
$article->archive();
133+
134+
return redirect()->back()->with('success', 'Article archived.');
135+
}
136+
137+
public function storeCategory(Request $request): RedirectResponse
138+
{
139+
$validated = $request->validate([
140+
'name' => 'required|string|max:255',
141+
'description' => 'nullable|string',
142+
'parent_id' => 'nullable|exists:kb_categories,id',
143+
'sequence' => 'nullable|integer',
144+
]);
145+
146+
$slug = \Illuminate\Support\Str::slug($validated['name']);
147+
$original = $slug;
148+
$count = 1;
149+
while (KbCategory::withoutGlobalScopes()->where('slug', $slug)->where('tenant_id', auth()->user()->tenant_id)->exists()) {
150+
$slug = $original . '-' . $count;
151+
$count++;
152+
}
153+
154+
KbCategory::create([
155+
...$validated,
156+
'tenant_id' => auth()->user()->tenant_id,
157+
'slug' => $slug,
158+
]);
159+
160+
return redirect()->back()->with('success', 'Category created.');
161+
}
162+
163+
public function search(Request $request): Response
164+
{
165+
$query = $request->get('q', '');
166+
167+
$articles = KbArticle::with(['category', 'author'])
168+
->where('status', 'published')
169+
->when($query, function ($q) use ($query) {
170+
$q->where(function ($q) use ($query) {
171+
$q->where('title', 'like', "%{$query}%")
172+
->orWhere('content', 'like', "%{$query}%");
173+
});
174+
})
175+
->orderByDesc('published_at')
176+
->paginate(20)
177+
->withQueryString();
178+
179+
return Inertia::render('KnowledgeBase/Search', [
180+
'articles' => $articles,
181+
'query' => $query,
182+
]);
183+
}
184+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace App\Modules\KnowledgeBase\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\SoftDeletes;
10+
use Illuminate\Support\Str;
11+
12+
class KbArticle extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $table = 'kb_articles';
17+
18+
protected $fillable = [
19+
'tenant_id',
20+
'category_id',
21+
'title',
22+
'slug',
23+
'content',
24+
'excerpt',
25+
'status',
26+
'author_id',
27+
'views',
28+
'tags',
29+
'published_at',
30+
];
31+
32+
protected $casts = [
33+
'tags' => 'array',
34+
'published_at' => 'datetime',
35+
];
36+
37+
public function category(): BelongsTo
38+
{
39+
return $this->belongsTo(KbCategory::class, 'category_id');
40+
}
41+
42+
public function author(): BelongsTo
43+
{
44+
return $this->belongsTo(User::class, 'author_id');
45+
}
46+
47+
public function publish(): void
48+
{
49+
$this->status = 'published';
50+
$this->published_at = now();
51+
$this->save();
52+
}
53+
54+
public function archive(): void
55+
{
56+
$this->status = 'archived';
57+
$this->save();
58+
}
59+
60+
public function incrementViews(): void
61+
{
62+
$this->increment('views');
63+
}
64+
65+
public function generateSlug(string $title): string
66+
{
67+
$slug = Str::slug($title);
68+
$original = $slug;
69+
$count = 1;
70+
71+
while (
72+
static::withoutGlobalScopes()
73+
->where('slug', $slug)
74+
->where('tenant_id', $this->tenant_id)
75+
->where('id', '!=', $this->id ?? 0)
76+
->exists()
77+
) {
78+
$slug = $original . '-' . $count;
79+
$count++;
80+
}
81+
82+
return $slug;
83+
}
84+
85+
public function isPublished(): bool
86+
{
87+
return $this->status === 'published';
88+
}
89+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace App\Modules\KnowledgeBase\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class KbCategory extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $table = 'kb_categories';
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'name',
20+
'slug',
21+
'description',
22+
'parent_id',
23+
'sequence',
24+
];
25+
26+
public function parent(): BelongsTo
27+
{
28+
return $this->belongsTo(KbCategory::class, 'parent_id');
29+
}
30+
31+
public function children(): HasMany
32+
{
33+
return $this->hasMany(KbCategory::class, 'parent_id')->orderBy('sequence');
34+
}
35+
36+
public function articles(): HasMany
37+
{
38+
return $this->hasMany(KbArticle::class, 'category_id');
39+
}
40+
41+
public function articleCount(): int
42+
{
43+
return $this->articles()->count();
44+
}
45+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Modules\KnowledgeBase\Providers;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
7+
class KnowledgeBaseServiceProvider extends ServiceProvider
8+
{
9+
public function register(): void {}
10+
11+
public function boot(): void
12+
{
13+
$this->loadRoutesFrom(__DIR__ . '/../routes/knowledge_base.php');
14+
$this->loadMigrationsFrom(__DIR__ . '/../../../database/migrations');
15+
}
16+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
use App\Modules\KnowledgeBase\Http\Controllers\KnowledgeBaseController;
4+
use Illuminate\Support\Facades\Route;
5+
6+
Route::middleware(['web', 'auth', 'verified'])->prefix('kb')->name('kb.')->group(function () {
7+
Route::get('categories', [KnowledgeBaseController::class, 'categories'])->name('categories');
8+
Route::post('categories', [KnowledgeBaseController::class, 'storeCategory'])->name('categories.store');
9+
Route::get('search', [KnowledgeBaseController::class, 'search'])->name('search');
10+
Route::post('{article}/publish', [KnowledgeBaseController::class, 'publish'])->name('publish');
11+
Route::post('{article}/archive', [KnowledgeBaseController::class, 'archive'])->name('archive');
12+
Route::get('', [KnowledgeBaseController::class, 'index'])->name('index');
13+
Route::post('', [KnowledgeBaseController::class, 'store'])->name('store');
14+
Route::get('{article}', [KnowledgeBaseController::class, 'show'])->name('show');
15+
Route::patch('{article}', [KnowledgeBaseController::class, 'update'])->name('update');
16+
Route::delete('{article}', [KnowledgeBaseController::class, 'destroy'])->name('destroy');
17+
});

0 commit comments

Comments
 (0)