diff --git a/Dockerfile b/Dockerfile index 5a35dfb..6dffabb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN apk add --no-cache \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ + libwebp-dev \ libxml2-dev \ libzip-dev \ icu-dev \ @@ -41,6 +42,7 @@ RUN apk add --no-cache \ RUN docker-php-ext-configure gd \ --with-freetype \ --with-jpeg \ + --with-webp \ && docker-php-ext-install -j"$(nproc)" \ pdo \ pdo_sqlite \ @@ -105,7 +107,7 @@ RUN mkdir -p \ RUN sed -i 's/user = www-data/user = www-data/' /usr/local/etc/php-fpm.d/www.conf 2>/dev/null || true \ && sed -i 's/group = www-data/group = www-data/' /usr/local/etc/php-fpm.d/www.conf 2>/dev/null || true -# ── OPcache tuning ─────────────────────────── +# ── PHP Tuning & Upload Limits ───────────────── RUN { \ echo "opcache.enable=1"; \ echo "opcache.memory_consumption=128"; \ @@ -115,6 +117,13 @@ RUN { \ echo "opcache.fast_shutdown=1"; \ } > /usr/local/etc/php/conf.d/opcache.ini +RUN { \ + echo "upload_max_filesize = 200M"; \ + echo "post_max_size = 200M"; \ + echo "memory_limit = 512M"; \ + echo "max_execution_time = 300"; \ +} > /usr/local/etc/php/conf.d/uploads.ini + EXPOSE 80 ENTRYPOINT ["/entrypoint.sh"] diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 8904bc4..edc1771 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -45,6 +45,7 @@ class InvoiceController extends Controller 'issue_date' => 'required|date', 'due_date' => 'required|date|after_or_equal:issue_date', 'notes' => 'nullable|string', + 'signature_image' => 'nullable|image|max:2048', 'bank_name' => 'nullable|string|max:100', 'bank_account' => 'nullable|string|max:50', 'bank_holder' => 'nullable|string|max:100', @@ -59,6 +60,18 @@ class InvoiceController extends Controller $items = $validated['items']; unset($validated['items']); $validated['status'] = InvoiceStatus::Draft->value; + + if ($request->hasFile('signature_image')) { + $validated['signature_image'] = $request->file('signature_image')->store('invoice-signatures', 'public'); + } else { + // Fallback to settings signature if none provided at creation + $settings = InvoiceSettings::instance(); + if ($settings->signature_image) { + // We reference the same image path, or copy it. + // Assuming referencing the same public image is fine. + $validated['signature_image'] = $settings->signature_image; + } + } $invoice = $this->invoiceService->create($validated, $items); @@ -94,6 +107,7 @@ class InvoiceController extends Controller 'due_date' => 'required|date', 'paid_date' => 'nullable|date', 'notes' => 'nullable|string', + 'signature_image' => 'nullable|image|max:2048', 'bank_name' => 'nullable|string|max:100', 'bank_account' => 'nullable|string|max:50', 'bank_holder' => 'nullable|string|max:100', @@ -107,6 +121,18 @@ class InvoiceController extends Controller $items = $validated['items']; unset($validated['items']); + + if ($request->hasFile('signature_image')) { + if ($invoice->signature_image && \Illuminate\Support\Facades\Storage::disk('public')->exists($invoice->signature_image)) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($invoice->signature_image); + } + $validated['signature_image'] = $request->file('signature_image')->store('invoice-signatures', 'public'); + } elseif ($request->boolean('remove_signature')) { + if ($invoice->signature_image && \Illuminate\Support\Facades\Storage::disk('public')->exists($invoice->signature_image)) { + \Illuminate\Support\Facades\Storage::disk('public')->delete($invoice->signature_image); + } + $validated['signature_image'] = null; + } $this->invoiceService->update($invoice, $validated, $items); diff --git a/app/Http/Controllers/InvoiceSettingsController.php b/app/Http/Controllers/InvoiceSettingsController.php index 6cb3d0b..00fd569 100644 --- a/app/Http/Controllers/InvoiceSettingsController.php +++ b/app/Http/Controllers/InvoiceSettingsController.php @@ -36,19 +36,34 @@ class InvoiceSettingsController extends Controller 'default_bank_account' => 'nullable|string|max:50', 'default_bank_holder' => 'nullable|string|max:100', 'default_notes' => 'nullable|string|max:1000', + 'signature_image' => 'nullable|image|max:2048', + 'signer_name' => 'nullable|string|max:100', + 'signer_title' => 'nullable|string|max:100', ]); $settings = InvoiceSettings::instance(); if ($request->hasFile('header_logo')) { // Delete old logo if exists - if ($settings->header_logo) { + if ($settings->header_logo && Storage::disk('public')->exists($settings->header_logo)) { Storage::disk('public')->delete($settings->header_logo); } $path = $request->file('header_logo')->store('invoice-logos', 'public'); $validated['header_logo'] = $path; } + if ($request->hasFile('signature_image')) { + if ($settings->signature_image && Storage::disk('public')->exists($settings->signature_image)) { + Storage::disk('public')->delete($settings->signature_image); + } + $validated['signature_image'] = $request->file('signature_image')->store('invoice-signatures', 'public'); + } elseif ($request->boolean('remove_signature')) { + if ($settings->signature_image && Storage::disk('public')->exists($settings->signature_image)) { + Storage::disk('public')->delete($settings->signature_image); + } + $validated['signature_image'] = null; + } + $settings->update($validated); return redirect()->route('invoice-settings.edit') @@ -69,7 +84,7 @@ class InvoiceSettingsController extends Controller $settings->update(['header_logo' => $path]); return response()->json([ - 'url' => asset('storage/' . $path), + 'url' => '/storage/' . $path, ]); } @@ -85,4 +100,36 @@ class InvoiceSettingsController extends Controller return redirect()->route('invoice-settings.edit') ->with('success', '🗑️ Logo removed.'); } + + public function uploadSignature(Request $request): JsonResponse + { + $request->validate([ + 'signature_image' => 'required|image|max:2048', + ]); + + $settings = InvoiceSettings::instance(); + + // Delete old signature if exists + if ($settings->signature_image && Storage::disk('public')->exists($settings->signature_image)) { + Storage::disk('public')->delete($settings->signature_image); + } + + $path = $request->file('signature_image')->store('invoice-signatures', 'public'); + $settings->update(['signature_image' => $path]); + + return response()->json([ + 'url' => '/storage/' . $path, + 'path' => $path + ]); + } + + public function deleteSignature(Request $request): JsonResponse + { + $settings = InvoiceSettings::instance(); + if ($settings->signature_image && Storage::disk('public')->exists($settings->signature_image)) { + Storage::disk('public')->delete($settings->signature_image); + } + $settings->update(['signature_image' => null]); + return response()->json(['message' => 'Signature deleted']); + } } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 9db9979..cd1b2fb 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -21,6 +21,7 @@ class Invoice extends Model 'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency', 'status', 'issue_date', 'due_date', 'paid_date', 'notes', 'bank_name', 'bank_account', 'bank_holder', 'branch_name', + 'signature_image', ]; protected $casts = [ diff --git a/app/Models/InvoiceSettings.php b/app/Models/InvoiceSettings.php index c9fc2af..6a7a941 100644 --- a/app/Models/InvoiceSettings.php +++ b/app/Models/InvoiceSettings.php @@ -25,6 +25,9 @@ class InvoiceSettings extends Model 'default_bank_account', 'default_bank_holder', 'default_notes', + 'signature_image', + 'signer_name', + 'signer_title', ]; protected $casts = [ diff --git a/database/migrations/2026_04_05_000000_add_signature_to_invoices_and_settings.php b/database/migrations/2026_04_05_000000_add_signature_to_invoices_and_settings.php new file mode 100644 index 0000000..1e9c0b6 --- /dev/null +++ b/database/migrations/2026_04_05_000000_add_signature_to_invoices_and_settings.php @@ -0,0 +1,30 @@ +string('signature_image')->nullable()->after('header_logo'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->string('signature_image')->nullable()->after('notes'); + }); + } + + public function down(): void + { + Schema::table('invoice_settings', function (Blueprint $table) { + $table->dropColumn('signature_image'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('signature_image'); + }); + } +}; diff --git a/database/migrations/2026_04_05_000001_add_signer_details_to_invoice_settings.php b/database/migrations/2026_04_05_000001_add_signer_details_to_invoice_settings.php new file mode 100644 index 0000000..68c59ad --- /dev/null +++ b/database/migrations/2026_04_05_000001_add_signer_details_to_invoice_settings.php @@ -0,0 +1,23 @@ +string('signer_name')->nullable()->after('signature_image'); + $table->string('signer_title')->nullable()->after('signer_name'); + }); + } + + public function down(): void + { + Schema::table('invoice_settings', function (Blueprint $table) { + $table->dropColumn(['signer_name', 'signer_title']); + }); + } +}; diff --git a/resources/views/invoices/create.blade.php b/resources/views/invoices/create.blade.php index a976d6a..49bb966 100644 --- a/resources/views/invoices/create.blade.php +++ b/resources/views/invoices/create.blade.php @@ -5,194 +5,202 @@ @section('page-subtitle', 'Fill in the invoice details') @section('content') -
-
-
- @csrf +
+
+ + @csrf - {{-- Header fields --}} -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - {{-- Line Items --}} -
-
-

Line Items

- + {{-- Header fields --}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- +
-
- {{-- Totals --}} -
-
- Subtotal (all items) - Rp + {{-- Totals --}} +
+
+ Subtotal (all items) + Rp +
+
+ Tax (% — paid by client) + Rp +
+
+ Grand Total + Rp +
-
- Tax (% — paid by client) - Rp -
-
- Grand Total - Rp -
-
- {{-- Bank Info --}} -
-
- - + {{-- Bank Info --}} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - -
-
- - -
-
- - -
-
-
- - -
+
+ + +
-
- - - Cancel - -
- +
+ +

Leave blank to use your default signature from settings.

+ +
+ +
+ + + Cancel + +
+ +
-
-@push('scripts') - -@endpush -@endsection + @push('scripts') + + @endpush +@endsection \ No newline at end of file diff --git a/resources/views/invoices/edit.blade.php b/resources/views/invoices/edit.blade.php index 6ee8b6a..908ac22 100644 --- a/resources/views/invoices/edit.blade.php +++ b/resources/views/invoices/edit.blade.php @@ -7,7 +7,7 @@ @section('content')
-
+ @csrf @method('PUT') {{-- Header fields --}} @@ -146,9 +146,39 @@
- +
+ +
+ +

Upload a specific signature for this invoice. Leave blank to keep the current one.

+ + @if($invoice->signature_image) +
+ Signature +
+ + +
+
+ @endif + + +
diff --git a/resources/views/invoices/pdf.blade.php b/resources/views/invoices/pdf.blade.php index 7ef0f1c..f96bf0e 100644 --- a/resources/views/invoices/pdf.blade.php +++ b/resources/views/invoices/pdf.blade.php @@ -361,9 +361,30 @@
@endif - @if($invoice->notes) -
{{ $invoice->notes }}
- @endif +
+ @if($invoice->notes) +
{{ $invoice->notes }}
+ @endif + + @if($invoice->signature_image && file_exists(storage_path('app/public/' . $invoice->signature_image))) +
+
+ Jakarta, {{ $invoice->issue_date->format('d F Y') }} +
+ +
+ {{ $invoiceSettings->signer_name ?: ($invoiceSettings->header_name ?: 'Signature') }} +
+ @if($invoiceSettings->signer_title || $invoiceSettings->header_title) +
+ {{ $invoiceSettings->signer_title ?: $invoiceSettings->header_title }} +
+ @endif +
+ @endif + +
+
diff --git a/resources/views/invoices/settings.blade.php b/resources/views/invoices/settings.blade.php index 7bc8b33..41651c7 100644 --- a/resources/views/invoices/settings.blade.php +++ b/resources/views/invoices/settings.blade.php @@ -62,7 +62,7 @@
@@ -205,6 +205,82 @@
+ +
+ +

Upload your signature here so it appears automatically on all new invoices.

+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+ + Uploading… + +
+
+
diff --git a/resources/views/invoices/show.blade.php b/resources/views/invoices/show.blade.php index abab3be..1b62137 100644 --- a/resources/views/invoices/show.blade.php +++ b/resources/views/invoices/show.blade.php @@ -147,9 +147,29 @@
@endif - @if($invoice->notes) -
{{ $invoice->notes }}
- @endif +
+
+ @if($invoice->notes) +
{{ $invoice->notes }}
+ @endif +
+ @if($invoice->signature_image) +
+
+ Jakarta, {{ $invoice->issue_date->format('d F Y') }} +
+ Signature +
+ {{ $invoiceSettings->signer_name ?: ($invoiceSettings->header_name ?: 'Signature') }} +
+ @if($invoiceSettings->signer_title || $invoiceSettings->header_title) +
+ {{ $invoiceSettings->signer_title ?: $invoiceSettings->header_title }} +
+ @endif +
+ @endif +
@endsection \ No newline at end of file diff --git a/resources/views/rate-cards/create.blade.php b/resources/views/rate-cards/create.blade.php index 221bd36..74c8cb0 100644 --- a/resources/views/rate-cards/create.blade.php +++ b/resources/views/rate-cards/create.blade.php @@ -33,10 +33,19 @@ -
+
- + +
diff --git a/resources/views/rate-cards/edit.blade.php b/resources/views/rate-cards/edit.blade.php index 0b6f567..4fa8624 100644 --- a/resources/views/rate-cards/edit.blade.php +++ b/resources/views/rate-cards/edit.blade.php @@ -33,10 +33,19 @@
-
+
- + +
diff --git a/routes/web.php b/routes/web.php index 3461631..2200680 100644 --- a/routes/web.php +++ b/routes/web.php @@ -76,6 +76,8 @@ Route::get('/invoice-settings', [InvoiceSettingsController::class, 'edit'])->nam Route::post('/invoice-settings', [InvoiceSettingsController::class, 'update'])->name('invoice-settings.update'); Route::post('/invoice-settings/logo', [InvoiceSettingsController::class, 'uploadLogo'])->name('invoice-settings.upload-logo'); Route::delete('/invoice-settings/logo', [InvoiceSettingsController::class, 'deleteLogo'])->name('invoice-settings.delete-logo'); +Route::post('/invoice-settings/signature', [InvoiceSettingsController::class, 'uploadSignature'])->name('invoice-settings.upload-signature'); +Route::delete('/invoice-settings/signature', [InvoiceSettingsController::class, 'deleteSignature'])->name('invoice-settings.delete-signature'); // Financial – Rate Cards Route::get('/rate-cards/public', [RateCardController::class, 'public'])->name('rate-cards.public');