feat: add custom signature image and signer details support to invoices and settings

This commit is contained in:
I-Fun
2026-04-05 12:03:11 +07:00
parent 64092d3abc
commit 12d44cbecc
15 changed files with 510 additions and 196 deletions

View File

@@ -5,194 +5,202 @@
@section('page-subtitle', 'Fill in the invoice details')
@section('content')
<div class="max-w-3xl mx-auto">
<div class="cat-card p-4 lg:p-8" x-data="invoiceForm()">
<form action="{{ route('invoices.store') }}" method="POST">
@csrf
<div class="max-w-3xl mx-auto">
<div class="cat-card p-4 lg:p-8" x-data="invoiceForm()">
<form action="{{ route('invoices.store') }}" method="POST" enctype="multipart/form-data">
@csrf
{{-- Header fields --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Invoice Title <span class="text-pink-400">*</span></label>
<input type="text" name="title" value="{{ old('title') }}" class="input-soft" required
placeholder="e.g. Instagram Collaboration - Brand X">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— No client —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}" {{ request('client') == $client->id ? 'selected' : '' }}>
{{ $client->name }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Currency</label>
<select name="currency" class="input-soft">
@foreach(['IDR' => 'IDR (Rupiah)', 'USD' => 'USD (Dollar)', 'SGD' => 'SGD', 'MYR' => 'MYR (Ringgit)'] as $code => $label)
<option value="{{ $code }}" {{ old('currency', $invoiceSettings->default_currency ?? 'IDR') === $code ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Issue Date</label>
<input type="date" name="issue_date" value="{{ old('issue_date', today()->toDateString()) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Due Date</label>
<input type="date" name="due_date"
value="{{ old('due_date', today()->addDays(14)->toDateString()) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Tax Rate (%)</label>
<input type="number" name="tax_rate" x-model="taxRate" @input="recalc()"
value="{{ old('tax_rate', $invoiceSettings->default_tax_rate ?? 11) }}" class="input-soft" step="0.01">
</div>
</div>
{{-- Line Items --}}
<div class="mb-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-700">Line Items</h3>
<button type="button" @click="addItem()" class="btn-sakura text-xs py-1.5 px-4">+ Add Item</button>
{{-- Header fields --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Invoice Title <span
class="text-pink-400">*</span></label>
<input type="text" name="title" value="{{ old('title') }}" class="input-soft" required
placeholder="e.g. Instagram Collaboration - Brand X">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— No client —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}" {{ request('client') == $client->id ? 'selected' : '' }}>
{{ $client->name }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Currency</label>
<select name="currency" class="input-soft">
@foreach(['IDR' => 'IDR (Rupiah)', 'USD' => 'USD (Dollar)', 'SGD' => 'SGD', 'MYR' => 'MYR (Ringgit)'] as $code => $label)
<option value="{{ $code }}" {{ old('currency', $invoiceSettings->default_currency ?? 'IDR') === $code ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Issue Date</label>
<input type="date" name="issue_date" value="{{ old('issue_date', today()->toDateString()) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Due Date</label>
<input type="date" name="due_date"
value="{{ old('due_date', today()->addDays(14)->toDateString()) }}" class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Tax Rate (%)</label>
<input type="number" name="tax_rate" x-model="taxRate" @input="recalc()"
value="{{ old('tax_rate', $invoiceSettings->default_tax_rate ?? 11) }}" class="input-soft"
step="0.01">
</div>
</div>
<div class="space-y-2">
<template x-for="(item, index) in items" :key="index">
<div class="bg-pink-50 rounded-2xl p-3">
{{-- Description full width --}}
<input type="text" :name="`items[${index}][description]`"
x-model="item.description"
class="input-soft text-sm mb-2 bg-white"
placeholder="Service description" required>
{{-- Qty / Price / Total in a row --}}
<div class="flex gap-2 items-center">
<div class="w-16 shrink-0">
<label class="text-xs text-gray-400 block mb-0.5">Qty</label>
<input type="number" :name="`items[${index}][quantity]`"
x-model.number="item.quantity" @input="recalc()"
class="input-soft text-sm text-center bg-white px-2" min="1">
</div>
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-0.5">Unit Price</label>
<input type="number" :name="`items[${index}][unit_price]`"
x-model.number="item.unit_price" @input="recalc()"
class="input-soft text-sm bg-white" min="0" step="1000"
placeholder="0">
</div>
<div class="w-24 shrink-0 text-right">
<label class="text-xs text-gray-400 block mb-0.5">Total</label>
<div class="text-sm font-bold text-gray-700 pt-2">
Rp <span x-text="formatNum(item.quantity * item.unit_price)"></span>
{{-- Line Items --}}
<div class="mb-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-700">Line Items</h3>
<button type="button" @click="addItem()" class="btn-sakura text-xs py-1.5 px-4">+ Add Item</button>
</div>
<div class="space-y-2">
<template x-for="(item, index) in items" :key="index">
<div class="bg-pink-50 rounded-2xl p-3">
{{-- Description full width --}}
<input type="text" :name="`items[${index}][description]`" x-model="item.description"
class="input-soft text-sm mb-2 bg-white" placeholder="Service description" required>
{{-- Qty / Price / Total in a row --}}
<div class="flex gap-2 items-center">
<div class="w-16 shrink-0">
<label class="text-xs text-gray-400 block mb-0.5">Qty</label>
<input type="number" :name="`items[${index}][quantity]`"
x-model.number="item.quantity" @input="recalc()"
class="input-soft text-sm text-center bg-white px-2" min="1">
</div>
</div>
<button type="button" @click="removeItem(index)"
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-0.5">Unit Price</label>
<input type="number" :name="`items[${index}][unit_price]`"
x-model.number="item.unit_price" @input="recalc()"
class="input-soft text-sm bg-white" min="0" step="1000" placeholder="0">
</div>
<div class="w-24 shrink-0 text-right">
<label class="text-xs text-gray-400 block mb-0.5">Total</label>
<div class="text-sm font-bold text-gray-700 pt-2">
Rp <span x-text="formatNum(item.quantity * item.unit_price)"></span>
</div>
</div>
<button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button>
</div>
{{-- Taxable checkbox --}}
<div class="mt-2 flex items-center gap-2">
<input type="hidden" :name="`items[${index}][is_taxable]`" value="0">
<input type="checkbox" :name="`items[${index}][is_taxable]`" :id="`taxable_${index}`"
x-model="item.is_taxable" @change="recalc()" value="1"
class="w-3.5 h-3.5 accent-pink-400">
<label :for="`taxable_${index}`"
class="text-xs text-gray-500 cursor-pointer select-none">
Taxable
</label>
</div>
</div>
{{-- Taxable checkbox --}}
<div class="mt-2 flex items-center gap-2">
<input type="hidden" :name="`items[${index}][is_taxable]`" value="0">
<input type="checkbox" :name="`items[${index}][is_taxable]`"
:id="`taxable_${index}`"
x-model="item.is_taxable" @change="recalc()"
value="1"
class="w-3.5 h-3.5 accent-pink-400">
<label :for="`taxable_${index}`" class="text-xs text-gray-500 cursor-pointer select-none">
Taxable
</label>
</div>
</div>
</template>
</template>
</div>
</div>
</div>
{{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal (all items)</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
{{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal (all items)</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
</div>
<div class="flex justify-between text-sm py-1 text-amber-600">
<span>Tax (<span x-text="taxRate"></span>% — paid by client)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
</div>
<div class="flex justify-between text-base font-bold py-2 mt-1 border-t border-pink-200">
<span class="text-gray-700">Grand Total</span>
<span class="text-pink-600">Rp <span x-text="formatNum(total)"></span></span>
</div>
</div>
<div class="flex justify-between text-sm py-1 text-amber-600">
<span>Tax (<span x-text="taxRate"></span>% — paid by client)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
</div>
<div class="flex justify-between text-base font-bold py-2 mt-1 border-t border-pink-200">
<span class="text-gray-700">Grand Total</span>
<span class="text-pink-600">Rp <span x-text="formatNum(total)"></span></span>
</div>
</div>
{{-- Bank Info --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Bank Name</label>
<input type="text" name="bank_name"
value="{{ old('bank_name', $invoiceSettings->default_bank_name) }}"
class="input-soft text-sm" placeholder="BCA, Mandiri...">
{{-- Bank Info --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Bank Name</label>
<input type="text" name="bank_name"
value="{{ old('bank_name', $invoiceSettings->default_bank_name) }}" class="input-soft text-sm"
placeholder="BCA, Mandiri...">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Branch Name</label>
<input type="text" name="branch_name"
value="{{ old('branch_name', $invoiceSettings->default_branch_name) }}"
class="input-soft text-sm" placeholder="e.g. KCP Sudirman">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Number</label>
<input type="text" name="bank_account"
value="{{ old('bank_account', $invoiceSettings->default_bank_account) }}"
class="input-soft text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Holder</label>
<input type="text" name="bank_holder"
value="{{ old('bank_holder', $invoiceSettings->default_bank_holder) }}"
class="input-soft text-sm">
</div>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Branch Name</label>
<input type="text" name="branch_name"
value="{{ old('branch_name', $invoiceSettings->default_branch_name) }}"
class="input-soft text-sm" placeholder="e.g. KCP Sudirman">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Number</label>
<input type="text" name="bank_account"
value="{{ old('bank_account', $invoiceSettings->default_bank_account) }}"
class="input-soft text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Holder</label>
<input type="text" name="bank_holder"
value="{{ old('bank_holder', $invoiceSettings->default_bank_holder) }}"
class="input-soft text-sm">
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="2" class="input-soft resize-none"
placeholder="Payment terms, etc.">{{ old('notes', $invoiceSettings->default_notes) }}</textarea>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes / Payment Terms</label>
<textarea name="notes" rows="2" class="input-soft resize-none"
placeholder="Payment terms, etc.">{{ old('notes', $invoiceSettings->default_notes) }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="submit" class="btn-sakura flex-1 py-3">🧾 Create Invoice</button>
<a href="{{ route('invoices.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-1">Custom Signature Image (Optional)</label>
<p class="text-xs text-gray-400 mb-2">Leave blank to use your default signature from settings.</p>
<input type="file" name="signature_image" accept="image/*" class="w-full text-sm text-gray-600
file:mr-3 file:py-1.5 file:px-4
file:rounded-full file:border-0
file:text-xs file:font-semibold
file:bg-pink-50 file:text-pink-600
hover:file:bg-pink-100 cursor-pointer">
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="submit" class="btn-sakura flex-1 py-3">🧾 Create Invoice</button>
<a href="{{ route('invoices.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
@push('scripts')
<script>
function invoiceForm() {
return {
items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true }],
taxRate: {{ $invoiceSettings->default_tax_rate ?? 11 }},
get subtotal() {
return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
},
get taxAmount() {
const taxable = this.items
.filter(i => i.is_taxable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
return Math.round(taxable * this.taxRate / 100);
},
get total() { return this.subtotal; }, // grand total = pre-tax (tax borne by client)
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() {},
formatNum(n) { return Number(n).toLocaleString('id-ID'); },
};
}
</script>
@endpush
@endsection
@push('scripts')
<script>
function invoiceForm() {
return {
items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true }],
taxRate: {{ $invoiceSettings->default_tax_rate ?? 11 }},
get subtotal() {
return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
},
get taxAmount() {
const taxable = this.items
.filter(i => i.is_taxable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
return Math.round(taxable * this.taxRate / 100);
},
get total() { return this.subtotal; }, // grand total = pre-tax (tax borne by client)
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() { },
formatNum(n) { return Number(n).toLocaleString('id-ID'); },
};
}
</script>
@endpush
@endsection