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') -