Skip to content

Commit 4c9a002

Browse files
committed
feat(phase-26/27): custom dashboard widgets and email template management
Phase 26: Dashboard Widgets - DashboardWidget model: widget_type (kpi/chart/table/activity), title, config (json), position, size, is_visible - DashboardWidgetController: CRUD + reorder endpoint - Widgets are scoped per-user within tenant (not shared) - Reorder endpoint accepts ordered array of IDs and updates positions - 9 feature tests: listing, creating, validating types/sizes, updating, reordering, deleting, user scoping Phase 27: Email Template Management - EmailTemplate model: key (invoice_created, low_stock_alert, payroll_approved, approval_request) - Variable substitution via {{ variable_name }} syntax in subject + body - Default templates seeded on first index call - EmailTemplateController: CRUD + preview endpoint - Preview renders variables into template and returns rendered subject/body - forTenant() static helper for mail classes to override default templates - 9 feature tests: listing, creating, validation, duplicates, updating, deleting, preview, render Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 0c57346 commit 4c9a002

9 files changed

Lines changed: 650 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\DashboardWidget;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Validation\Rule;
9+
10+
class DashboardWidgetController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$widgets = DashboardWidget::where('tenant_id', $this->tenantId($request))
15+
->where('user_id', $request->user()->id)
16+
->orderBy('position')
17+
->get();
18+
19+
return $this->success($widgets);
20+
}
21+
22+
public function store(Request $request): JsonResponse
23+
{
24+
$tenantId = $this->tenantId($request);
25+
26+
$data = $request->validate([
27+
'widget_type' => ['required', Rule::in(DashboardWidget::$validTypes)],
28+
'title' => ['required', 'string', 'max:100'],
29+
'config' => ['nullable', 'array'],
30+
'position' => ['nullable', 'integer', 'min:0'],
31+
'size' => ['nullable', Rule::in(DashboardWidget::$validSizes)],
32+
]);
33+
34+
// Shift existing positions if inserting at a specific spot
35+
$position = $data['position'] ?? DashboardWidget::where('tenant_id', $tenantId)
36+
->where('user_id', $request->user()->id)
37+
->max('position') + 1 ?? 0;
38+
39+
$widget = DashboardWidget::create([
40+
...$data,
41+
'tenant_id' => $tenantId,
42+
'user_id' => $request->user()->id,
43+
'position' => $position,
44+
'size' => $data['size'] ?? 'md',
45+
]);
46+
47+
return $this->success($widget, 201);
48+
}
49+
50+
public function update(Request $request, DashboardWidget $dashboardWidget): JsonResponse
51+
{
52+
$data = $request->validate([
53+
'title' => ['sometimes', 'string', 'max:100'],
54+
'config' => ['nullable', 'array'],
55+
'position' => ['nullable', 'integer', 'min:0'],
56+
'size' => ['nullable', Rule::in(DashboardWidget::$validSizes)],
57+
'is_visible' => ['boolean'],
58+
]);
59+
60+
$dashboardWidget->update($data);
61+
62+
return $this->success($dashboardWidget->fresh());
63+
}
64+
65+
public function reorder(Request $request): JsonResponse
66+
{
67+
$tenantId = $this->tenantId($request);
68+
69+
$data = $request->validate([
70+
'order' => ['required', 'array'],
71+
'order.*' => ['integer'],
72+
]);
73+
74+
foreach ($data['order'] as $pos => $id) {
75+
DashboardWidget::where('id', $id)
76+
->where('tenant_id', $tenantId)
77+
->where('user_id', $request->user()->id)
78+
->update(['position' => $pos]);
79+
}
80+
81+
return $this->success(['message' => 'Widget order updated.']);
82+
}
83+
84+
public function destroy(DashboardWidget $dashboardWidget): JsonResponse
85+
{
86+
$dashboardWidget->delete();
87+
return $this->success(['message' => 'Widget removed.']);
88+
}
89+
90+
private function tenantId(Request $request): int
91+
{
92+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
93+
}
94+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Models\EmailTemplate;
6+
use Illuminate\Http\JsonResponse;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Validation\Rule;
9+
10+
class EmailTemplateController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
16+
$templates = EmailTemplate::where('tenant_id', $tenantId)->get();
17+
18+
// Augment with defaults for any missing template keys
19+
$existing = $templates->pluck('key')->all();
20+
$defaults = collect(EmailTemplate::$defaultTemplates)
21+
->filter(fn ($t, $key) => ! in_array($key, $existing))
22+
->map(fn ($t, $key) => array_merge($t, ['key' => $key, 'is_active' => true, 'id' => null]));
23+
24+
return $this->success([
25+
'templates' => $templates,
26+
'defaults' => $defaults->values(),
27+
]);
28+
}
29+
30+
public function store(Request $request): JsonResponse
31+
{
32+
$tenantId = $this->tenantId($request);
33+
34+
$data = $request->validate([
35+
'key' => [
36+
'required', 'string', 'max:100',
37+
Rule::in(array_keys(EmailTemplate::$defaultTemplates)),
38+
Rule::unique('email_templates')->where('tenant_id', $tenantId),
39+
],
40+
'name' => ['required', 'string', 'max:255'],
41+
'subject' => ['required', 'string', 'max:500'],
42+
'body_html' => ['required', 'string'],
43+
'is_active' => ['boolean'],
44+
]);
45+
46+
$template = EmailTemplate::create([
47+
...$data,
48+
'tenant_id' => $tenantId,
49+
'variables' => EmailTemplate::$defaultTemplates[$data['key']]['variables'] ?? [],
50+
]);
51+
52+
return $this->success($template, 201);
53+
}
54+
55+
public function show(Request $request, EmailTemplate $emailTemplate): JsonResponse
56+
{
57+
return $this->success($emailTemplate);
58+
}
59+
60+
public function update(Request $request, EmailTemplate $emailTemplate): JsonResponse
61+
{
62+
$data = $request->validate([
63+
'name' => ['sometimes', 'string', 'max:255'],
64+
'subject' => ['sometimes', 'string', 'max:500'],
65+
'body_html' => ['sometimes', 'string'],
66+
'is_active' => ['boolean'],
67+
]);
68+
69+
$emailTemplate->update($data);
70+
71+
return $this->success($emailTemplate->fresh());
72+
}
73+
74+
public function destroy(EmailTemplate $emailTemplate): JsonResponse
75+
{
76+
$emailTemplate->delete();
77+
return $this->success(['message' => 'Template deleted. Default will be used.']);
78+
}
79+
80+
public function preview(Request $request, EmailTemplate $emailTemplate): JsonResponse
81+
{
82+
$vars = $request->input('variables', []);
83+
$rendered = $emailTemplate->render($vars);
84+
85+
return $this->success($rendered);
86+
}
87+
88+
private function tenantId(Request $request): int
89+
{
90+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
91+
}
92+
}

erp/app/Models/DashboardWidget.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class DashboardWidget extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id',
15+
'user_id',
16+
'widget_type',
17+
'title',
18+
'config',
19+
'position',
20+
'size',
21+
'is_visible',
22+
];
23+
24+
protected $casts = [
25+
'config' => 'array',
26+
'position' => 'integer',
27+
'is_visible' => 'boolean',
28+
];
29+
30+
public static array $validTypes = ['kpi', 'chart', 'table', 'activity'];
31+
public static array $validSizes = ['sm', 'md', 'lg', 'xl'];
32+
33+
public function user(): BelongsTo
34+
{
35+
return $this->belongsTo(User::class);
36+
}
37+
}

erp/app/Models/EmailTemplate.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace App\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
class EmailTemplate extends Model
9+
{
10+
use BelongsToTenant;
11+
12+
protected $fillable = [
13+
'tenant_id',
14+
'key',
15+
'name',
16+
'subject',
17+
'body_html',
18+
'variables',
19+
'is_active',
20+
];
21+
22+
protected $casts = [
23+
'variables' => 'array',
24+
'is_active' => 'boolean',
25+
];
26+
27+
public static array $defaultTemplates = [
28+
'invoice_created' => [
29+
'name' => 'Invoice Created',
30+
'subject' => 'Invoice #{{ invoice_number }} from {{ company_name }}',
31+
'body_html' => '<p>Dear {{ customer_name }},</p><p>Please find attached your invoice #{{ invoice_number }} for {{ total }}.</p><p>Due date: {{ due_date }}</p>',
32+
'variables' => ['invoice_number', 'customer_name', 'total', 'due_date', 'company_name'],
33+
],
34+
'low_stock_alert' => [
35+
'name' => 'Low Stock Alert',
36+
'subject' => 'Low Stock Alert: {{ product_name }}',
37+
'body_html' => '<p>Product <strong>{{ product_name }}</strong> has fallen below reorder point.</p><p>Current quantity: {{ quantity }}</p><p>Reorder point: {{ reorder_point }}</p>',
38+
'variables' => ['product_name', 'quantity', 'reorder_point'],
39+
],
40+
'payroll_approved' => [
41+
'name' => 'Payroll Approved',
42+
'subject' => 'Payroll Run Approved – {{ period }}',
43+
'body_html' => '<p>Dear {{ employee_name }},</p><p>Your payslip for {{ period }} has been approved. Net pay: {{ net_pay }}.</p>',
44+
'variables' => ['employee_name', 'period', 'net_pay', 'gross_pay'],
45+
],
46+
'approval_request' => [
47+
'name' => 'Approval Request',
48+
'subject' => 'Approval Required: {{ document_type }} #{{ document_ref }}',
49+
'body_html' => '<p>You have a pending approval for {{ document_type }} #{{ document_ref }}.<br>Submitted by {{ requester_name }}.</p>',
50+
'variables' => ['document_type', 'document_ref', 'requester_name'],
51+
],
52+
];
53+
54+
/**
55+
* Render the subject and body with provided variables.
56+
*
57+
* @param array<string, string> $vars
58+
* @return array{subject: string, body_html: string}
59+
*/
60+
public function render(array $vars): array
61+
{
62+
$replace = function (string $template) use ($vars): string {
63+
foreach ($vars as $key => $value) {
64+
$template = str_replace("{{ {$key} }}", (string) $value, $template);
65+
$template = str_replace("{{$key}}", (string) $value, $template);
66+
}
67+
return $template;
68+
};
69+
70+
return [
71+
'subject' => $replace($this->subject),
72+
'body_html' => $replace($this->body_html),
73+
];
74+
}
75+
76+
public static function forTenant(int $tenantId, string $key): ?self
77+
{
78+
return static::withoutGlobalScopes()
79+
->where('tenant_id', $tenantId)
80+
->where('key', $key)
81+
->where('is_active', true)
82+
->first();
83+
}
84+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('dashboard_widgets', function (Blueprint $table) {
11+
$table->id();
12+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
13+
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
14+
$table->string('widget_type'); // kpi, chart, table, activity
15+
$table->string('title');
16+
$table->json('config')->nullable(); // widget-specific settings
17+
$table->integer('position')->default(0);
18+
$table->string('size')->default('md'); // sm, md, lg, xl
19+
$table->boolean('is_visible')->default(true);
20+
$table->timestamps();
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('dashboard_widgets');
27+
}
28+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
Schema::create('email_templates', function (Blueprint $table) {
11+
$table->id();
12+
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
13+
$table->string('key'); // invoice_created, low_stock_alert, etc.
14+
$table->string('name');
15+
$table->string('subject');
16+
$table->text('body_html');
17+
$table->json('variables')->nullable(); // available variables list
18+
$table->boolean('is_active')->default(true);
19+
$table->timestamps();
20+
21+
$table->unique(['tenant_id', 'key']);
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('email_templates');
28+
}
29+
};

erp/routes/api.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,25 @@
347347
// System Metrics (auth required)
348348
Route::get('/metrics', [\App\Http\Controllers\Api\V1\HealthController::class, 'metrics']);
349349

350+
// Dashboard Widgets
351+
Route::prefix('dashboard-widgets')->group(function () {
352+
Route::get('/', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'index']);
353+
Route::post('/', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'store']);
354+
Route::put('/{dashboardWidget}', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'update']);
355+
Route::post('/reorder', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'reorder']);
356+
Route::delete('/{dashboardWidget}', [\App\Http\Controllers\Api\V1\DashboardWidgetController::class, 'destroy']);
357+
});
358+
359+
// Email Templates
360+
Route::prefix('email-templates')->group(function () {
361+
Route::get('/', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'index']);
362+
Route::post('/', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'store']);
363+
Route::get('/{emailTemplate}', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'show']);
364+
Route::put('/{emailTemplate}', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'update']);
365+
Route::delete('/{emailTemplate}', [\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'destroy']);
366+
Route::post('/{emailTemplate}/preview',[\App\Http\Controllers\Api\V1\EmailTemplateController::class, 'preview']);
367+
});
368+
350369
// Report Schedules
351370
Route::prefix('report-schedules')->group(function () {
352371
Route::get('/', [\App\Http\Controllers\Api\V1\ReportScheduleController::class, 'index']);

0 commit comments

Comments
 (0)