Skip to content

Commit ff6fc83

Browse files
committed
feat(phase-5): Quality Control, Equipment Maintenance, Sprint/Gantt, Marketing Analytics modules
Phase 5A - Quality Control module: - QcChecklist, QcChecklistItem, QcInspection, QcInspectionResult, NonConformanceReport models - QualityControlController with dashboard, checklists, inspections, NCR CRUD - 5 migrations (quality_checklists, quality_checklist_items, quality_inspections, quality_inspection_results, non_conformance_reports) — renamed from qc_* to quality_* to avoid collision with Inventory module's existing qc_* tables - 4 React pages: Dashboard, Checklists/Index, Inspections/Index, NCR/Index - 12 Pest tests with makeQcChecklist/makeQcInspection helpers Phase 5B - Equipment Maintenance module: - Equipment, MaintenancePlan, MaintenanceOrder models with lifecycle methods - MaintenanceController with dashboard/equipment/orders/plans CRUD + start/complete order - 3 migrations (equipment, maintenance_plans, maintenance_orders) - 4 React pages: Dashboard, Equipment/Index, Orders/Index, Plans/Index - 12 Pest tests with makeMaintEquipment/makeMaintPlan/makeMaintOrder helpers - MaintenanceServiceProvider registered in CoreServiceProvider Phase 5C - Advanced PM (Sprints + Gantt): - TaskDependency, ProjectSprint models; Task/Project models updated with sprint relationships - SprintController (index/store/activate/complete), GanttController (show/data JSON) - 3 migrations (task_dependencies, project_sprints, add sprint columns to tasks) - Sprint/Index.tsx and Gantt.tsx React pages - 10 Pest tests with makePmSprintProject/makePmSprint helpers Phase 5D - Marketing Analytics + A/B Testing: - CampaignEvent, AbTestVariant models; EmailCampaign updated with analytics relationships - MarketingAnalyticsController (index/campaignStats/trackEvent/storeAbVariant/declareWinner) - 2 migrations (campaign_events, ab_test_variants) - Marketing/Analytics/Index.tsx React page - 10 Pest tests with makeMktgCampaign/makeMktgEvent/makeMktgVariant helpers - Fixed undefined array key 'sent' in campaignStats; fixed float JSON comparison Schema fixes (pre-existing mismatches resolved): - bank_accounts: removed opening_balance from Phase 4 schema; updated tests/models/controller - subscriptions: aligned test+model+controller to 2026_12_17 schema (plan_id, customer_name) - survey_responses: renamed HR module table to employee_survey_responses to avoid collision with general Survey module's survey_responses table - makeCategory helper renamed to makeKbCategory in KnowledgeBaseTest to avoid collision with EcommerceTest helper All 2195 tests passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 4ef79ad commit ff6fc83

70 files changed

Lines changed: 4673 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use App\Modules\KnowledgeBase\Providers\KnowledgeBaseServiceProvider;
3131
use App\Modules\Planning\Providers\PlanningServiceProvider;
3232
use App\Modules\Sign\Providers\SignServiceProvider;
33+
use App\Modules\Maintenance\Providers\MaintenanceServiceProvider;
3334
use Illuminate\Support\Facades\Gate;
3435
use Illuminate\Support\ServiceProvider;
3536

@@ -61,6 +62,7 @@ public function register(): void
6162
$this->app->register(KnowledgeBaseServiceProvider::class);
6263
$this->app->register(PlanningServiceProvider::class);
6364
$this->app->register(SignServiceProvider::class);
65+
$this->app->register(MaintenanceServiceProvider::class);
6466
}
6567

6668
public function boot(): void

erp/app/Modules/Finance/Http/Controllers/BankAccountController.php

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,13 @@ public function store(Request $request): RedirectResponse
4040
'bank_name' => 'required|string|max:255',
4141
'account_number' => 'nullable|string|max:255',
4242
'currency' => 'nullable|string|max:10',
43-
'opening_balance' => 'nullable|numeric',
4443
]);
4544

4645
$account = BankAccount::create([
4746
...$data,
48-
'tenant_id' => $request->user()->tenant_id,
49-
'currency' => $data['currency'] ?? 'USD',
50-
'opening_balance' => $data['opening_balance'] ?? 0,
47+
'tenant_id' => $request->user()->tenant_id,
48+
'currency' => $data['currency'] ?? 'USD',
5149
]);
52-
$account->updateBalance();
5350

5451
return redirect()->back()
5552
->with('success', 'Bank account created.');
@@ -86,15 +83,13 @@ public function update(Request $request, BankAccount $bankAccount): RedirectResp
8683
$this->authorize('update', $bankAccount);
8784

8885
$data = $request->validate([
89-
'name' => 'required|string|max:255',
90-
'bank_name' => 'required|string|max:255',
91-
'account_number' => 'nullable|string|max:255',
92-
'currency' => 'nullable|string|max:10',
93-
'opening_balance' => 'nullable|numeric',
86+
'name' => 'required|string|max:255',
87+
'bank_name' => 'required|string|max:255',
88+
'account_number' => 'nullable|string|max:255',
89+
'currency' => 'nullable|string|max:10',
9490
]);
9591

9692
$bankAccount->update($data);
97-
$bankAccount->updateBalance();
9893

9994
return redirect()->back()
10095
->with('success', 'Bank account updated.');

erp/app/Modules/Finance/Http/Controllers/SubscriptionController.php

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function index(Request $request): Response
1919
{
2020
$this->authorize('viewAny', Subscription::class);
2121

22-
$subscriptions = Subscription::with(['contact', 'plan'])
22+
$subscriptions = Subscription::with(['plan'])
2323
->when($request->status, fn ($q) => $q->where('status', $request->status))
2424
->latest()
2525
->paginate(15)
@@ -55,28 +55,33 @@ public function store(Request $request): RedirectResponse
5555
$this->authorize('create', Subscription::class);
5656

5757
$data = $request->validate([
58-
'contact_id' => ['required', Rule::exists('contacts', 'id')],
59-
'subscription_plan_id' => ['required', Rule::exists('subscription_plans', 'id')],
60-
'started_at' => ['required', 'date'],
58+
'plan_id' => ['required', Rule::exists('subscription_plans', 'id')],
59+
'customer_name' => ['nullable', 'string', 'max:255'],
60+
'customer_email' => ['nullable', 'email', 'max:255'],
61+
'current_period_start' => ['nullable', 'date'],
62+
'current_period_end' => ['nullable', 'date'],
6163
'notes' => ['nullable', 'string'],
6264
]);
6365

64-
$plan = SubscriptionPlan::find($data['subscription_plan_id']);
66+
$plan = SubscriptionPlan::find($data['plan_id']);
6567

6668
$trialEndsAt = null;
6769
$status = 'active';
70+
$today = Carbon::today()->toDateString();
6871

6972
if ($plan && $plan->trial_days > 0) {
7073
$status = 'trial';
71-
$trialEndsAt = Carbon::parse($data['started_at'])->addDays($plan->trial_days)->toDateString();
74+
$trialEndsAt = Carbon::today()->addDays($plan->trial_days)->toDateString();
7275
}
7376

7477
$subscription = Subscription::create([
7578
'tenant_id' => auth()->user()->tenant_id,
76-
'contact_id' => $data['contact_id'],
77-
'subscription_plan_id' => $data['subscription_plan_id'],
79+
'plan_id' => $data['plan_id'],
80+
'customer_name' => $data['customer_name'] ?? null,
81+
'customer_email' => $data['customer_email'] ?? null,
7882
'status' => $status,
79-
'started_at' => $data['started_at'],
83+
'current_period_start' => $data['current_period_start'] ?? $today,
84+
'current_period_end' => $data['current_period_end'] ?? ($plan ? $plan->getNextBillingDate($today) : null),
8085
'trial_ends_at' => $trialEndsAt,
8186
'notes' => $data['notes'] ?? null,
8287
]);
@@ -89,7 +94,7 @@ public function show(Subscription $subscription): Response
8994
{
9095
$this->authorize('view', $subscription);
9196

92-
$subscription->load(['contact', 'plan']);
97+
$subscription->load(['plan']);
9398

9499
return Inertia::render('Finance/Subscriptions/Show', [
95100
'subscription' => $subscription,

erp/app/Modules/Finance/Http/Controllers/SubscriptionPlanController.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,23 @@ public function store(Request $request): RedirectResponse
4949

5050
$data = $request->validate([
5151
'name' => ['required', 'string', 'max:255'],
52-
'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'annually'])],
52+
'billing_cycle' => ['required', Rule::in(['monthly', 'quarterly', 'annual', 'annually'])],
5353
'price' => ['required', 'numeric', 'min:0'],
54-
'currency_code' => ['nullable', 'string', 'size:3'],
5554
'trial_days' => ['nullable', 'integer', 'min:0'],
5655
'description' => ['nullable', 'string'],
5756
'is_active' => ['nullable', 'boolean'],
5857
]);
5958

59+
// Normalize 'annually' to 'annual' for DB compatibility
60+
if (($data['billing_cycle'] ?? '') === 'annually') {
61+
$data['billing_cycle'] = 'annual';
62+
}
63+
6064
$plan = SubscriptionPlan::create([
6165
'tenant_id' => auth()->user()->tenant_id,
6266
'name' => $data['name'],
6367
'billing_cycle' => $data['billing_cycle'],
6468
'price' => $data['price'],
65-
'currency_code' => $data['currency_code'] ?? 'USD',
6669
'trial_days' => $data['trial_days'] ?? 0,
6770
'description' => $data['description'] ?? null,
6871
'is_active' => $data['is_active'] ?? true,

erp/app/Modules/Finance/Models/BankAccount.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ class BankAccount extends Model
1414

1515
protected $fillable = [
1616
'tenant_id', 'name', 'account_number', 'bank_name',
17-
'currency_code', 'opening_balance',
18-
'currency', 'current_balance', 'is_active',
17+
'currency_code', 'currency', 'current_balance', 'is_active',
1918
];
2019

2120
protected $casts = [
22-
'opening_balance' => 'float',
2321
'current_balance' => 'float',
2422
'is_active' => 'boolean',
2523
];
@@ -36,7 +34,7 @@ public function reconciliations(): HasMany
3634

3735
public function getBalanceAttribute(): float
3836
{
39-
return $this->opening_balance + $this->transactions()->sum('amount');
37+
return (float) $this->current_balance;
4038
}
4139

4240
public function getUnreconciledCountAttribute(): int
@@ -46,7 +44,7 @@ public function getUnreconciledCountAttribute(): int
4644

4745
public function updateBalance(): void
4846
{
49-
$this->current_balance = $this->opening_balance + $this->transactions()->sum('amount');
47+
$this->current_balance = $this->transactions()->sum('amount');
5048
$this->save();
5149
}
5250
}

erp/app/Modules/Finance/Models/Subscription.php

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,21 @@ class Subscription extends Model
1515
use SoftDeletes;
1616

1717
protected $fillable = [
18-
'tenant_id', 'contact_id', 'subscription_plan_id', 'status',
19-
'started_at', 'trial_ends_at', 'current_period_start', 'current_period_end',
20-
'cancelled_at', 'next_invoice_date', 'notes',
18+
'tenant_id', 'plan_id', 'customer_name', 'customer_email', 'status',
19+
'trial_ends_at', 'current_period_start', 'current_period_end',
20+
'cancelled_at', 'notes',
2121
];
2222

2323
protected $casts = [
24-
'started_at' => 'date',
2524
'trial_ends_at' => 'date',
2625
'current_period_start' => 'date',
2726
'current_period_end' => 'date',
2827
'cancelled_at' => 'datetime',
29-
'next_invoice_date' => 'date',
3028
];
3129

32-
public function contact(): BelongsTo
33-
{
34-
return $this->belongsTo(Contact::class);
35-
}
36-
3730
public function plan(): BelongsTo
3831
{
39-
return $this->belongsTo(SubscriptionPlan::class, 'subscription_plan_id');
32+
return $this->belongsTo(SubscriptionPlan::class, 'plan_id');
4033
}
4134

4235
public function activate(): void
@@ -45,7 +38,6 @@ public function activate(): void
4538
$this->status = 'active';
4639
$this->current_period_start = $today;
4740
$this->current_period_end = $this->plan->getNextBillingDate($today);
48-
$this->next_invoice_date = $today;
4941
$this->save();
5042
}
5143

@@ -79,7 +71,6 @@ public function generateInvoice(): Invoice
7971
$invoice = DB::transaction(function () use ($today, $plan, $period) {
8072
$inv = Invoice::create([
8173
'tenant_id' => $this->tenant_id,
82-
'contact_id' => $this->contact_id,
8374
'status' => 'draft',
8475
'issue_date' => $today,
8576
'due_date' => Carbon::today()->addDays(30)->toDateString(),
@@ -96,9 +87,6 @@ public function generateInvoice(): Invoice
9687
return $inv;
9788
});
9889

99-
$this->next_invoice_date = $plan->getNextBillingDate($today);
100-
$this->save();
101-
10290
return $invoice;
10391
}
10492
}

erp/app/Modules/Finance/Models/SubscriptionPlan.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class SubscriptionPlan extends Model
1515

1616
protected $fillable = [
1717
'tenant_id', 'name', 'description', 'billing_cycle',
18-
'price', 'currency_code', 'trial_days', 'is_active',
18+
'price', 'trial_days', 'is_active',
1919
];
2020

2121
protected $casts = [
@@ -33,6 +33,7 @@ public function getNextBillingDate(string $from): string
3333
return match ($this->billing_cycle) {
3434
'monthly' => Carbon::parse($from)->addMonth()->toDateString(),
3535
'quarterly' => Carbon::parse($from)->addMonths(3)->toDateString(),
36+
'annual' => Carbon::parse($from)->addYear()->toDateString(),
3637
'annually' => Carbon::parse($from)->addYear()->toDateString(),
3738
default => Carbon::parse($from)->addMonth()->toDateString(),
3839
};

erp/app/Modules/HR/Models/SurveyResponse.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class SurveyResponse extends Model
1010
{
1111
use BelongsToTenant;
1212

13+
protected $table = 'employee_survey_responses';
14+
1315
protected $fillable = [
1416
'tenant_id', 'employee_survey_id', 'employee_id', 'answers', 'submitted_at',
1517
];
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
namespace App\Modules\Maintenance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Maintenance\Models\Equipment;
7+
use App\Modules\Maintenance\Models\MaintenancePlan;
8+
use App\Modules\Maintenance\Models\MaintenanceOrder;
9+
use App\Models\User;
10+
use Illuminate\Http\{JsonResponse, RedirectResponse, Request};
11+
use Inertia\{Inertia, Response};
12+
13+
class MaintenanceController extends Controller
14+
{
15+
public function dashboard(): Response
16+
{
17+
$tenantId = app('tenant')->id;
18+
return Inertia::render('Maintenance/Dashboard', [
19+
'stats' => [
20+
'total_equipment' => Equipment::withoutGlobalScopes()->where('tenant_id', $tenantId)->count(),
21+
'operational' => Equipment::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('status', 'operational')->count(),
22+
'open_orders' => MaintenanceOrder::withoutGlobalScopes()->where('tenant_id', $tenantId)->whereIn('status', ['open', 'in_progress'])->count(),
23+
'overdue_plans' => MaintenancePlan::withoutGlobalScopes()->where('tenant_id', $tenantId)->where('is_active', true)->where('next_due_at', '<', now())->count(),
24+
],
25+
]);
26+
}
27+
28+
public function equipment(): Response
29+
{
30+
$equipment = Equipment::withoutGlobalScopes()
31+
->where('tenant_id', app('tenant')->id)
32+
->with('assignedUser')
33+
->orderBy('name')
34+
->paginate(20);
35+
return Inertia::render('Maintenance/Equipment/Index', ['equipment' => $equipment]);
36+
}
37+
38+
public function storeEquipment(Request $request): RedirectResponse
39+
{
40+
$validated = $request->validate([
41+
'name' => 'required|string|max:255',
42+
'code' => 'nullable|string|max:100',
43+
'category' => 'required|in:machinery,electrical,hvac,vehicle,it,other',
44+
'location' => 'nullable|string|max:255',
45+
'serial_number' => 'nullable|string|max:255',
46+
'manufacturer' => 'nullable|string|max:255',
47+
'model' => 'nullable|string|max:255',
48+
'purchase_date' => 'nullable|date',
49+
'warranty_expiry' => 'nullable|date',
50+
'status' => 'sometimes|in:operational,under_maintenance,out_of_service,retired',
51+
'assigned_to' => 'nullable|exists:users,id',
52+
]);
53+
Equipment::create(['tenant_id' => app('tenant')->id] + $validated);
54+
return redirect()->route('maintenance.equipment')->with('success', 'Equipment added.');
55+
}
56+
57+
public function orders(): Response
58+
{
59+
$orders = MaintenanceOrder::withoutGlobalScopes()
60+
->where('tenant_id', app('tenant')->id)
61+
->with(['equipment', 'assignedUser'])
62+
->orderByDesc('created_at')
63+
->paginate(20);
64+
return Inertia::render('Maintenance/Orders/Index', ['orders' => $orders]);
65+
}
66+
67+
public function storeOrder(Request $request): RedirectResponse
68+
{
69+
$validated = $request->validate([
70+
'equipment_id' => 'required|exists:equipment,id',
71+
'type' => 'required|in:preventive,corrective,emergency',
72+
'priority' => 'required|in:low,medium,high,critical',
73+
'title' => 'required|string|max:255',
74+
'description' => 'nullable|string',
75+
'scheduled_date' => 'nullable|date',
76+
'estimated_hours' => 'nullable|numeric|min:0',
77+
'assigned_to' => 'nullable|exists:users,id',
78+
]);
79+
$validated['order_number'] = MaintenanceOrder::generateOrderNumber(app('tenant')->id);
80+
MaintenanceOrder::create(['tenant_id' => app('tenant')->id, 'reported_by' => auth()->id()] + $validated);
81+
return redirect()->route('maintenance.orders')->with('success', 'Order created.');
82+
}
83+
84+
public function startOrder(MaintenanceOrder $order): RedirectResponse
85+
{
86+
$order->start();
87+
return redirect()->back()->with('success', 'Order started.');
88+
}
89+
90+
public function completeOrder(Request $request, MaintenanceOrder $order): RedirectResponse
91+
{
92+
$validated = $request->validate([
93+
'resolution' => 'required|string',
94+
'actual_hours' => 'required|numeric|min:0',
95+
'cost' => 'nullable|numeric|min:0',
96+
]);
97+
$order->complete($validated['resolution'], (float) $validated['actual_hours']);
98+
if (isset($validated['cost'])) {
99+
$order->update(['cost' => $validated['cost']]);
100+
}
101+
if ($order->plan_id) {
102+
$order->plan?->markPerformed();
103+
}
104+
return redirect()->back()->with('success', 'Order completed.');
105+
}
106+
107+
public function plans(): Response
108+
{
109+
$plans = MaintenancePlan::withoutGlobalScopes()
110+
->where('tenant_id', app('tenant')->id)
111+
->with('equipment')
112+
->orderBy('next_due_at')
113+
->get();
114+
return Inertia::render('Maintenance/Plans/Index', ['plans' => $plans]);
115+
}
116+
117+
public function storePlan(Request $request): RedirectResponse
118+
{
119+
$validated = $request->validate([
120+
'equipment_id' => 'required|exists:equipment,id',
121+
'name' => 'required|string|max:255',
122+
'frequency' => 'required|in:daily,weekly,monthly,quarterly,annual,as_needed',
123+
'estimated_duration_hours' => 'nullable|numeric|min:0',
124+
'description' => 'nullable|string',
125+
]);
126+
$plan = MaintenancePlan::create(['tenant_id' => app('tenant')->id] + $validated);
127+
$plan->next_due_at = $plan->calculateNextDue();
128+
$plan->save();
129+
return redirect()->route('maintenance.plans')->with('success', 'Maintenance plan created.');
130+
}
131+
}

0 commit comments

Comments
 (0)