Skip to content

Commit 8647dd3

Browse files
committed
feat(finance): Phase 57 — Document Templates with variable substitution
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 368cf63 commit 8647dd3

13 files changed

Lines changed: 1022 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\DocumentTemplate;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class DocumentTemplateController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', DocumentTemplate::class);
19+
20+
$query = DocumentTemplate::orderByDesc('created_at');
21+
22+
if ($request->filled('type')) {
23+
$query->forType($request->type);
24+
}
25+
26+
$templates = $query->paginate(15)->withQueryString();
27+
28+
return Inertia::render('Finance/DocumentTemplates/Index', [
29+
'templates' => $templates,
30+
'filter' => ['type' => $request->get('type', '')],
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', DocumentTemplate::class);
37+
38+
return Inertia::render('Finance/DocumentTemplates/Create');
39+
}
40+
41+
public function store(Request $request): RedirectResponse
42+
{
43+
$this->authorize('create', DocumentTemplate::class);
44+
45+
$data = $request->validate([
46+
'name' => ['required', 'string', 'max:255'],
47+
'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])],
48+
'subject' => ['nullable', 'string', 'max:255'],
49+
'body' => ['required', 'string'],
50+
'variables' => ['nullable', 'array'],
51+
'is_default' => ['boolean'],
52+
'is_active' => ['boolean'],
53+
]);
54+
55+
$template = DocumentTemplate::create([
56+
'tenant_id' => auth()->user()->tenant_id,
57+
'name' => $data['name'],
58+
'type' => $data['type'],
59+
'subject' => $data['subject'] ?? null,
60+
'body' => $data['body'],
61+
'variables' => $data['variables'] ?? null,
62+
'is_default' => $data['is_default'] ?? false,
63+
'is_active' => $data['is_active'] ?? true,
64+
]);
65+
66+
return redirect()->route('finance.document-templates.show', $template)
67+
->with('success', 'Document template created.');
68+
}
69+
70+
public function show(DocumentTemplate $documentTemplate): Response
71+
{
72+
$this->authorize('view', $documentTemplate);
73+
74+
return Inertia::render('Finance/DocumentTemplates/Show', [
75+
'template' => $documentTemplate,
76+
]);
77+
}
78+
79+
public function edit(DocumentTemplate $documentTemplate): Response
80+
{
81+
$this->authorize('create', DocumentTemplate::class);
82+
83+
return Inertia::render('Finance/DocumentTemplates/Edit', [
84+
'template' => $documentTemplate,
85+
]);
86+
}
87+
88+
public function update(Request $request, DocumentTemplate $documentTemplate): RedirectResponse
89+
{
90+
$this->authorize('create', DocumentTemplate::class);
91+
92+
$data = $request->validate([
93+
'name' => ['required', 'string', 'max:255'],
94+
'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])],
95+
'subject' => ['nullable', 'string', 'max:255'],
96+
'body' => ['required', 'string'],
97+
'variables' => ['nullable', 'array'],
98+
'is_default' => ['boolean'],
99+
'is_active' => ['boolean'],
100+
]);
101+
102+
$documentTemplate->update($data);
103+
104+
return redirect()->route('finance.document-templates.show', $documentTemplate)
105+
->with('success', 'Document template updated.');
106+
}
107+
108+
public function destroy(DocumentTemplate $documentTemplate): RedirectResponse
109+
{
110+
$this->authorize('delete', $documentTemplate);
111+
112+
$documentTemplate->delete();
113+
114+
return redirect()->route('finance.document-templates.index')
115+
->with('success', 'Document template deleted.');
116+
}
117+
118+
public function preview(Request $request): JsonResponse
119+
{
120+
$this->authorize('viewAny', DocumentTemplate::class);
121+
122+
$request->validate([
123+
'type' => ['required', Rule::in(['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])],
124+
'data' => ['nullable', 'json'],
125+
]);
126+
127+
$template = DocumentTemplate::forType($request->type)->active()->firstOrFail();
128+
129+
$data = $request->filled('data') ? json_decode($request->data, true) : [];
130+
131+
return response()->json(['html' => $template->render($data)]);
132+
}
133+
}
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\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\SoftDeletes;
8+
use Illuminate\Database\Eloquent\Builder;
9+
10+
class DocumentTemplate extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id', 'name', 'type', 'subject', 'body', 'variables', 'is_default', 'is_active',
16+
];
17+
18+
protected $casts = [
19+
'variables' => 'array',
20+
'is_default' => 'boolean',
21+
'is_active' => 'boolean',
22+
];
23+
24+
public function render(array $data): string
25+
{
26+
$rendered = $this->body;
27+
foreach ($data as $key => $value) {
28+
$rendered = str_replace('{{' . $key . '}}', $value, $rendered);
29+
}
30+
return $rendered;
31+
}
32+
33+
public function scopeForType(Builder $query, string $type): Builder
34+
{
35+
return $query->where('type', $type);
36+
}
37+
38+
public function scopeActive(Builder $query): Builder
39+
{
40+
return $query->where('is_active', true);
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\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\DocumentTemplate;
7+
8+
class DocumentTemplatePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, DocumentTemplate $model): bool
16+
{
17+
return $user->hasPermissionTo('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('finance.create');
23+
}
24+
25+
public function update(User $user, DocumentTemplate $model): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function delete(User $user, DocumentTemplate $model): bool
31+
{
32+
return $user->hasPermissionTo('finance.delete');
33+
}
34+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@
2222
use App\Modules\Finance\Models\FixedAsset;
2323
use App\Modules\Finance\Models\Attachment;
2424
use App\Modules\Finance\Models\BatchPayment;
25+
use App\Modules\Finance\Models\DocumentTemplate;
2526
use App\Modules\Finance\Models\Project;
2627
use App\Modules\Finance\Policies\AttachmentPolicy;
2728
use App\Modules\Finance\Policies\BatchPaymentPolicy;
29+
use App\Modules\Finance\Policies\DocumentTemplatePolicy;
2830
use App\Modules\Finance\Policies\AccountPolicy;
2931
use App\Modules\Finance\Policies\DeliveryNotePolicy;
3032
use App\Modules\Finance\Policies\PriceListPolicy;
@@ -74,6 +76,7 @@ public function boot(): void
7476
Gate::policy(Project::class, ProjectPolicy::class);
7577
Gate::policy(Attachment::class, AttachmentPolicy::class);
7678
Gate::policy(BatchPayment::class, BatchPaymentPolicy::class);
79+
Gate::policy(DocumentTemplate::class, DocumentTemplatePolicy::class);
7780

7881
if ($this->app->runningInConsole()) {
7982
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

erp/app/Modules/Finance/routes/finance.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use Illuminate\Support\Facades\Route;
2626
use App\Modules\Finance\Http\Controllers\VendorProfileController;
2727
use App\Modules\Finance\Http\Controllers\VendorEvaluationController;
28+
use App\Modules\Finance\Http\Controllers\DocumentTemplateController;
2829

2930
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
3031

@@ -190,6 +191,11 @@
190191
Route::post('vendors/{contact}/evaluations', [VendorEvaluationController::class, 'store'])->name('vendors.evaluations.store');
191192
Route::delete('vendors/{contact}/evaluations/{evaluation}', [VendorEvaluationController::class, 'destroy'])->name('vendors.evaluations.destroy');
192193

194+
// Document Templates
195+
Route::get('document-templates/preview', [DocumentTemplateController::class, 'preview'])->name('document-templates.preview');
196+
Route::resource('document-templates', DocumentTemplateController::class)->except(['show']);
197+
Route::get('document-templates/{documentTemplate}', [DocumentTemplateController::class, 'show'])->name('document-templates.show');
198+
193199
// Customer Portal Token (admin generates token for a contact)
194200
Route::post('contacts/{contact}/portal-token', [\App\Modules\Finance\Http\Controllers\CustomerPortalController::class, 'generateToken'])->name('contacts.portal-token');
195201

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
{
9+
public function up(): void
10+
{
11+
Schema::create('document_templates', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->enum('type', ['invoice', 'quote', 'letter', 'receipt', 'purchase_order'])->default('invoice');
16+
$table->string('subject')->nullable();
17+
$table->longText('body');
18+
$table->json('variables')->nullable();
19+
$table->boolean('is_default')->default(false);
20+
$table->boolean('is_active')->default(true);
21+
$table->softDeletes();
22+
$table->timestamps();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('document_templates');
29+
}
30+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const navItems: NavItem[] = [
108108
{ label: 'Price Lists', href: '/finance/price-lists', icon: <span /> },
109109
{ label: 'Projects', href: '/finance/projects', icon: <span /> },
110110
{ label: 'Batch Payments', href: '/finance/batch-payments', icon: <span /> },
111+
{ label: 'Doc Templates', href: '/finance/document-templates', icon: <span /> },
111112
],
112113
},
113114
{

0 commit comments

Comments
 (0)