Compare commits

...

5 Commits

23 changed files with 1327 additions and 465 deletions

View File

@@ -94,13 +94,11 @@ class ClientController extends Controller
public function show(Client $client): View public function show(Client $client): View
{ {
$client->load([ $client->load('category');
'category', $client->setRelation('events', $client->events()->latest()->limit(5)->get());
'events' => fn ($q) => $q->latest()->limit(5), $client->setRelation('invoices', $client->invoices()->latest()->limit(5)->get());
'invoices' => fn ($q) => $q->latest()->limit(5), $client->setRelation('contentBriefs', $client->contentBriefs()->latest()->limit(5)->get());
'contentBriefs'=> fn ($q) => $q->latest()->limit(5), $client->setRelation('mediaFiles', $client->mediaFiles()->latest()->limit(10)->get());
'mediaFiles' => fn ($q) => $q->latest()->limit(10),
]);
return view('clients.show', compact('client')); return view('clients.show', compact('client'));
} }

View File

@@ -28,10 +28,9 @@ class InvoiceController extends Controller
public function create(): View public function create(): View
{ {
$clients = Client::where('status', 'active')->orderBy('name')->get(); $clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases(); $statuses = InvoiceStatus::cases();
$invoiceSettings = InvoiceSettings::instance(); return view('invoices.create', compact('clients', 'statuses'));
return view('invoices.create', compact('clients', 'statuses', 'invoiceSettings'));
} }
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
@@ -57,6 +56,8 @@ class InvoiceController extends Controller
'items.*.quantity' => 'required|integer|min:1', 'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0', 'items.*.unit_price' => 'required|numeric|min:0',
'items.*.is_taxable' => 'nullable|boolean', 'items.*.is_taxable' => 'nullable|boolean',
'items.*.is_discountable' => 'nullable|boolean',
'discount_rate' => 'nullable|integer|min:0|max:100',
]); ]);
$items = $validated['items']; $items = $validated['items'];
@@ -84,17 +85,15 @@ class InvoiceController extends Controller
public function show(Invoice $invoice): View public function show(Invoice $invoice): View
{ {
$invoice->load('client', 'items'); $invoice->load('client', 'items');
$invoiceSettings = InvoiceSettings::instance(); return view('invoices.show', compact('invoice'));
return view('invoices.show', compact('invoice', 'invoiceSettings'));
} }
public function edit(Invoice $invoice): View public function edit(Invoice $invoice): View
{ {
$clients = Client::where('status', 'active')->orderBy('name')->get(); $clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases(); $statuses = InvoiceStatus::cases();
$invoiceSettings = InvoiceSettings::instance();
$invoice->load('client', 'items'); $invoice->load('client', 'items');
return view('invoices.edit', compact('invoice', 'clients', 'statuses', 'invoiceSettings')); return view('invoices.edit', compact('invoice', 'clients', 'statuses'));
} }
public function update(Request $request, Invoice $invoice): RedirectResponse public function update(Request $request, Invoice $invoice): RedirectResponse
@@ -121,6 +120,8 @@ class InvoiceController extends Controller
'items.*.quantity' => 'required|integer|min:1', 'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0', 'items.*.unit_price' => 'required|numeric|min:0',
'items.*.is_taxable' => 'nullable|boolean', 'items.*.is_taxable' => 'nullable|boolean',
'items.*.is_discountable' => 'nullable|boolean',
'discount_rate' => 'nullable|integer|min:0|max:100',
]); ]);
$items = $validated['items']; $items = $validated['items'];
@@ -154,4 +155,9 @@ class InvoiceController extends Controller
{ {
return $this->invoiceService->generatePdf($invoice); return $this->invoiceService->generatePdf($invoice);
} }
public function pdfLegacy(Invoice $invoice): Response
{
return $this->invoiceService->generateLegacyPdf($invoice);
}
} }

View File

@@ -17,8 +17,7 @@ class RateCardController extends Controller
public function index(): View public function index(): View
{ {
$rateCards = RateCard::orderBy('sort_order')->orderBy('platform')->get(); $rateCards = RateCard::orderBy('sort_order')->orderBy('platform')->get();
$profile = RateCardProfile::getSingleton(); return view('rate-cards.index', compact('rateCards'));
return view('rate-cards.index', compact('rateCards', 'profile'));
} }
public function create(): View public function create(): View
@@ -88,22 +87,21 @@ class RateCardController extends Controller
public function public(): View public function public(): View
{ {
$profile = RateCardProfile::getSingleton();
$rateCards = RateCard::where('is_active', true) $rateCards = RateCard::where('is_active', true)
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('platform') ->orderBy('platform')
->get() ->get()
->groupBy('platform'); ->groupBy('platform');
return view('rate-cards.public', compact('rateCards', 'profile')); return view('rate-cards.public', compact('rateCards'));
} }
// ── Profile Settings ───────────────────────────────────────── // ── Profile Settings ─────────────────────────────────────────
public function profilePage(): View public function profilePage(): View
{ {
$profile = RateCardProfile::getSingleton(); $mediaFiles = \App\Models\MediaFile::latest()->get();
return view('rate-cards.profile', compact('profile')); return view('rate-cards.profile', compact('mediaFiles'));
} }
public function profileUpdate(Request $request): RedirectResponse public function profileUpdate(Request $request): RedirectResponse
@@ -125,6 +123,13 @@ class RateCardController extends Controller
'brand_logo_size' => 'nullable|in:small,medium,large', 'brand_logo_size' => 'nullable|in:small,medium,large',
'contact_person_html' => 'nullable|string', 'contact_person_html' => 'nullable|string',
'logo' => 'nullable|image|max:2048', 'logo' => 'nullable|image|max:2048',
'content_showcase' => 'nullable|array',
'content_showcase.*.file_id' => 'required_with:content_showcase|string',
'content_showcase.*.name' => 'required_with:content_showcase|string|max:255',
'content_showcase.*.url' => 'nullable|url',
'content_showcase.*.thumbnail_path' => 'nullable|string',
'content_showcase.*.show_on_public' => 'nullable',
'content_showcase.*.show_on_pdf' => 'nullable',
]); ]);
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();
@@ -135,6 +140,12 @@ class RateCardController extends Controller
} }
$validated['show_prices'] = $request->has('show_prices'); $validated['show_prices'] = $request->has('show_prices');
$contentShowcase = $validated['content_showcase'] ?? [];
foreach ($contentShowcase as &$item) {
$item['show_on_public'] = filter_var($item['show_on_public'] ?? true, FILTER_VALIDATE_BOOLEAN);
$item['show_on_pdf'] = filter_var($item['show_on_pdf'] ?? true, FILTER_VALIDATE_BOOLEAN);
}
$validated['content_showcase'] = $contentShowcase;
unset($validated['logo']); unset($validated['logo']);
$profile->update($validated); $profile->update($validated);
@@ -192,6 +203,7 @@ class RateCardController extends Controller
public function deleteBrandLogo(Request $request) public function deleteBrandLogo(Request $request)
{ {
$request->validate(['path' => 'required|string']);
$path = $request->input('path'); $path = $request->input('path');
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();
$brands = $profile->past_brands ?? []; $brands = $profile->past_brands ?? [];
@@ -221,6 +233,17 @@ class RateCardController extends Controller
// ── PDF Download ───────────────────────────────────────────── // ── PDF Download ─────────────────────────────────────────────
public function uploadShowcaseThumbnail(Request $request)
{
$request->validate(['thumbnail' => 'required|image|max:2048']);
$path = $request->file('thumbnail')->store('showcase', 'public');
return response()->json([
'path' => $path,
'url' => asset('storage/' . $path)
]);
}
public function pdf(): \Illuminate\Http\Response public function pdf(): \Illuminate\Http\Response
{ {
$profile = RateCardProfile::getSingleton(); $profile = RateCardProfile::getSingleton();

View File

@@ -13,7 +13,7 @@ class GateAuth
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response
{ {
// Allow the login routes through without auth check // Allow the login routes through without auth check
if ($request->routeIs('gate.login', 'gate.attempt', 'gate.logout')) { if ($request->routeIs('gate.login', 'gate.attempt', 'gate.logout', 'rate-cards.public')) {
return $next($request); return $next($request);
} }

View File

@@ -21,7 +21,7 @@ class Invoice extends Model
'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency', 'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency',
'status', 'issue_date', 'due_date', 'paid_date', 'notes', 'status', 'issue_date', 'due_date', 'paid_date', 'notes',
'bank_name', 'bank_account', 'bank_holder', 'branch_name', 'bank_name', 'bank_account', 'bank_holder', 'branch_name',
'signature_image', 'signer_name', 'signer_title', 'signature_image', 'signer_name', 'signer_title', 'discount_rate',
]; ];
protected $casts = [ protected $casts = [
@@ -47,14 +47,32 @@ class Invoice extends Model
public function recalculate(): void public function recalculate(): void
{ {
// Total of all items (what the creator receives) // Total of all items
$this->subtotal = $this->items->sum('total'); $this->subtotal = $this->items->sum('total');
// Tax is calculated only on taxable items, but paid by the client on top $discountAmount = 0;
$taxableSubtotal = $this->items->where('is_taxable', true)->sum('total'); if ($this->discount_rate > 0) {
$discountableSubtotal = $this->items->where('is_discountable', true)->sum('total');
$discountAmount = round((float) $discountableSubtotal * ($this->discount_rate / 100), 2);
}
$this->subtotal = $this->subtotal - $discountAmount;
// Tax is calculated only on taxable items
$taxableSubtotal = 0;
foreach ($this->items as $item) {
if ($item->is_taxable) {
if ($this->discount_rate > 0 && $item->is_discountable) {
$taxableSubtotal += $item->total * (1 - $this->discount_rate / 100);
} else {
$taxableSubtotal += $item->total;
}
}
}
$this->tax_amount = round((float) $taxableSubtotal * ($this->tax_rate / 100), 2); $this->tax_amount = round((float) $taxableSubtotal * ($this->tax_rate / 100), 2);
// Grand total = subtotal (creator's fee, pre-tax — tax is borne by client separately) // Grand total = subtotal (tax is borne by client separately)
$this->total = $this->subtotal; $this->total = $this->subtotal;
$this->save(); $this->save();
} }
@@ -64,6 +82,14 @@ class Invoice extends Model
return 'Rp ' . number_format((float) $this->total, 0, ',', '.'); return 'Rp ' . number_format((float) $this->total, 0, ',', '.');
} }
public function getDiscountAmountAttribute(): float
{
if ($this->discount_rate <= 0) return 0.0;
$discountableSubtotal = $this->items->where('is_discountable', true)->sum('total');
return round((float) $discountableSubtotal * ($this->discount_rate / 100), 2);
}
public static function generateNumber(): string public static function generateNumber(): string
{ {
return InvoiceSettings::generateNumber(); return InvoiceSettings::generateNumber();

View File

@@ -9,12 +9,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoiceItem extends Model class InvoiceItem extends Model
{ {
protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total', 'is_taxable']; protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total', 'is_taxable', 'is_discountable'];
protected $casts = [ protected $casts = [
'unit_price' => 'decimal:2', 'unit_price' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'is_taxable' => 'boolean', 'is_taxable' => 'boolean',
'is_discountable' => 'boolean',
]; ];
public function invoice(): BelongsTo public function invoice(): BelongsTo

View File

@@ -35,25 +35,39 @@ class InvoiceSettings extends Model
'default_tax_rate' => 'decimal:2', 'default_tax_rate' => 'decimal:2',
]; ];
protected static ?self $instance = null;
/** Always return the single settings row, creating it if absent. */ /** Always return the single settings row, creating it if absent. */
public static function instance(): self public static function instance(): self
{ {
return static::firstOrCreate([], [ if (self::$instance) {
return self::$instance;
}
self::$instance = static::firstOrCreate([], [
'invoice_prefix' => 'INV', 'invoice_prefix' => 'INV',
'invoice_next_number' => 1, 'invoice_next_number' => 1,
'accent_color' => '#FFB7C5', 'accent_color' => '#FFB7C5',
'default_tax_rate' => 2.5, 'default_tax_rate' => 2.5,
'default_currency' => 'IDR', 'default_currency' => 'IDR',
]); ]);
return self::$instance;
}
protected static function booted()
{
static::saved(fn ($model) => self::$instance = $model);
static::deleted(fn () => self::$instance = null);
} }
/** Generate the next invoice number and increment the counter. */
public static function generateNumber(): string public static function generateNumber(): string
{ {
$settings = static::instance(); $settings = static::instance();
$prefix = rtrim($settings->invoice_prefix, '-'); $prefix = rtrim($settings->invoice_prefix, '-');
$number = str_pad((string) $settings->invoice_next_number, 3, '0', STR_PAD_LEFT); $number = str_pad((string) $settings->invoice_next_number, 3, '0', STR_PAD_LEFT);
$settings->increment('invoice_next_number'); $settings->increment('invoice_next_number');
\Illuminate\Support\Facades\Cache::forget('invoice_settings');
return $prefix . '-' . $number; return $prefix . '-' . $number;
} }
} }

View File

@@ -17,23 +17,39 @@ class RateCardProfile extends Model
'past_brands', 'past_brands',
'brand_logo_size', 'brand_logo_size',
'contact_person_html', 'contact_person_html',
'content_showcase',
]; ];
protected $casts = [ protected $casts = [
'show_prices' => 'boolean', 'show_prices' => 'boolean',
'past_brands' => 'array', 'past_brands' => 'array',
'content_showcase' => 'array',
]; ];
protected static ?self $instance = null;
/** Get the singleton profile, creating defaults if needed */ /** Get the singleton profile, creating defaults if needed */
public static function getSingleton(): self public static function getSingleton(): self
{ {
return static::firstOrCreate([], [ if (self::$instance) {
return self::$instance;
}
self::$instance = static::firstOrCreate([], [
'owner_name' => config('app.name', 'Cathy Page'), 'owner_name' => config('app.name', 'Cathy Page'),
'bg_color' => '#FFF8F0', 'bg_color' => '#FFF8F0',
'accent_color' => '#FFB7C5', 'accent_color' => '#FFB7C5',
'text_color' => '#374151', 'text_color' => '#374151',
'show_prices' => true, 'show_prices' => true,
]); ]);
return self::$instance;
}
protected static function booted()
{
static::saved(fn ($model) => self::$instance = $model);
static::deleted(fn () => self::$instance = null);
} }
public function getWhatsappUrlAttribute(): ?string public function getWhatsappUrlAttribute(): ?string

View File

@@ -16,6 +16,13 @@ class AppServiceProvider extends ServiceProvider
{ {
// Inject alert badge count into all views // Inject alert badge count into all views
View::composer('*', function (\Illuminate\View\View $view): void { View::composer('*', function (\Illuminate\View\View $view): void {
if (!$view->offsetExists('profile')) {
$view->with('profile', \App\Models\RateCardProfile::getSingleton());
}
if (!$view->offsetExists('invoiceSettings')) {
$view->with('invoiceSettings', \App\Models\InvoiceSettings::instance());
}
try { try {
$alertCount = Event::upcoming(24)->count(); $alertCount = Event::upcoming(24)->count();
} catch (\Throwable) { } catch (\Throwable) {

View File

@@ -59,4 +59,15 @@ final class InvoiceService
return $pdf->download("invoice-{$invoice->invoice_number}.pdf"); return $pdf->download("invoice-{$invoice->invoice_number}.pdf");
} }
public function generateLegacyPdf(Invoice $invoice): Response
{
$invoice->load('client', 'items');
$invoiceSettings = InvoiceSettings::instance();
$pdf = Pdf::loadView('invoices.pdf-legacy', compact('invoice', 'invoiceSettings'));
$pdf->setPaper('A4', 'portrait');
return $pdf->download("invoice-{$invoice->invoice_number}-legacy.pdf");
}
} }

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('rate_card_profiles', function (Blueprint $table) {
$table->json('content_showcase')->nullable();
});
}
public function down(): void
{
Schema::table('rate_card_profiles', function (Blueprint $table) {
$table->dropColumn('content_showcase');
});
}
};

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
if (!Schema::hasColumn('invoices', 'discount_rate')) {
$table->integer('discount_rate')->default(0);
}
});
Schema::table('invoice_items', function (Blueprint $table) {
if (!Schema::hasColumn('invoice_items', 'is_discountable')) {
$table->boolean('is_discountable')->default(true);
}
});
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
if (Schema::hasColumn('invoices', 'discount_rate')) {
$table->dropColumn('discount_rate');
}
});
Schema::table('invoice_items', function (Blueprint $table) {
if (Schema::hasColumn('invoice_items', 'is_discountable')) {
$table->dropColumn('is_discountable');
}
});
}
};

View File

@@ -98,16 +98,28 @@
<button type="button" @click="removeItem(index)" <button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button> class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button>
</div> </div>
{{-- Taxable checkbox --}} {{-- Taxable and Discount checkboxes --}}
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-4">
<input type="hidden" :name="`items[${index}][is_taxable]`" value="0"> <div class="flex items-center gap-2">
<input type="checkbox" :name="`items[${index}][is_taxable]`" :id="`taxable_${index}`" <input type="hidden" :name="`items[${index}][is_taxable]`" value="0">
x-model="item.is_taxable" @change="recalc()" value="1" <input type="checkbox" :name="`items[${index}][is_taxable]`" :id="`taxable_${index}`"
class="w-3.5 h-3.5 accent-pink-400"> x-model="item.is_taxable" @change="recalc()" value="1"
<label :for="`taxable_${index}`" class="w-3.5 h-3.5 accent-pink-400">
class="text-xs text-gray-500 cursor-pointer select-none"> <label :for="`taxable_${index}`"
Taxable class="text-xs text-gray-500 cursor-pointer select-none">
</label> Taxable
</label>
</div>
<div class="flex items-center gap-2" x-show="addDiscount">
<input type="hidden" :name="`items[${index}][is_discountable]`" value="0">
<input type="checkbox" :name="`items[${index}][is_discountable]`" :id="`discountable_${index}`"
x-model="item.is_discountable" @change="recalc()" value="1"
class="w-3.5 h-3.5 accent-pink-400">
<label :for="`discountable_${index}`"
class="text-xs text-gray-500 cursor-pointer select-none">
Discount
</label>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -116,10 +128,26 @@
{{-- Totals --}} {{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5"> <div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="mb-3 border-b border-pink-100 pb-3 flex flex-col sm:flex-row sm:items-center gap-y-2 gap-x-4 min-h-[32px]">
<label class="flex items-center gap-2 cursor-pointer shrink-0 w-max">
<input type="checkbox" x-model="addDiscount" @change="toggleDiscount()" class="w-4 h-4 accent-pink-400">
<span class="text-sm font-bold text-gray-700">Add Discount</span>
</label>
<div x-show="addDiscount" class="flex items-center gap-3 flex-1 overflow-hidden" style="display: none;">
<input type="range" name="discount_rate" min="5" max="90" step="5" x-model.number="discountRate" @input="recalc()" class="w-32 sm:w-48 accent-pink-500 h-2 bg-pink-100 rounded-lg appearance-none cursor-pointer shrink-0 hover:accent-pink-600 transition-all">
<span class="font-bold text-pink-600 text-sm shrink-0 w-8 text-right"><span x-text="discountRate"></span>%</span>
<span class="text-xs text-pink-500 font-medium tracking-wide truncate border-l border-pink-200 pl-3" x-text="getDiscountLabel()"></span>
</div>
</div>
<div class="flex justify-between text-sm py-1"> <div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal (all items)</span> <span class="text-gray-500">Subtotal (all items)</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span> <span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
</div> </div>
<div class="flex justify-between text-sm py-1 text-pink-500" x-show="addDiscount && discountAmount > 0">
<span>Discount (<span x-text="discountRate"></span>%)</span>
<span class="font-semibold">- Rp <span x-text="formatNum(discountAmount)"></span></span>
</div>
<div class="flex justify-between text-sm py-1 text-amber-600"> <div class="flex justify-between text-sm py-1 text-amber-600">
<span>Tax (<span x-text="taxRate"></span>% — paid by client)</span> <span>Tax (<span x-text="taxRate"></span>% — paid by client)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span> <span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
@@ -211,19 +239,68 @@
function invoiceForm() { function invoiceForm() {
return { return {
showCustomFields: {{ old('use_custom_fields', false) ? 'true' : 'false' }}, showCustomFields: {{ old('use_custom_fields', false) ? 'true' : 'false' }},
items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true }], addDiscount: false,
discountRate: 5,
items: [{ description: @json($invoiceSettings->default_service_description ?? ''), quantity: 1, unit_price: 0, is_taxable: true, is_discountable: false }],
taxRate: {{ $invoiceSettings->default_tax_rate ?? 11 }}, taxRate: {{ $invoiceSettings->default_tax_rate ?? 11 }},
get subtotal() { get subtotal() {
return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
}, },
get discountAmount() {
if (!this.addDiscount) return 0;
const discountable = this.items
.filter(i => i.is_discountable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
return Math.round(discountable * (this.discountRate / 100));
},
get totalSubtotal() {
return this.subtotal - this.discountAmount;
},
get taxAmount() { get taxAmount() {
const taxable = this.items const taxable = this.items
.filter(i => i.is_taxable) .filter(i => i.is_taxable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0); .reduce((s, i) => {
let itemTotal = i.quantity * i.unit_price;
if (this.addDiscount && i.is_discountable) {
itemTotal -= itemTotal * (this.discountRate / 100);
}
return s + itemTotal;
}, 0);
return Math.round(taxable * this.taxRate / 100); return Math.round(taxable * this.taxRate / 100);
}, },
get total() { return this.subtotal; }, // grand total = pre-tax (tax borne by client) get total() { return this.totalSubtotal; }, // grand total = pre-tax
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); }, getDiscountLabel() {
const labels = {
5: 'Small thank-you discount 🙏',
10: 'Appreciation bonus ✨',
15: 'Loyalty perk unlocked 🤝',
20: 'Client appreciation deal 💼',
25: 'Special partner discount ⭐',
30: 'Valued client offer 🎯',
35: 'Priority client savings 📌',
40: 'Strong appreciation rate 💎',
45: 'Preferred client pricing 🏅',
50: 'Half-off partner special 🎉',
55: 'Premium loyalty reward 🧩',
60: 'Major appreciation offer 🚀',
65: 'VIP-level savings 🏆',
70: 'Elite client advantage 👑',
75: 'Strategic partner bonus 🤝✨',
80: 'Executive-tier discount 💼🔥',
85: 'Signature client reward 🖋️',
90: 'Ultimate partnership rate 🌟'
};
return labels[this.discountRate] || '';
},
toggleDiscount() {
if (this.addDiscount) {
this.items.forEach(i => i.is_discountable = true);
} else {
// We can leave them checked or uncheck them, usually uncheck
this.items.forEach(i => i.is_discountable = false);
}
},
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true, is_discountable: this.addDiscount }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() { }, recalc() { },
formatNum(n) { return Number(n).toLocaleString('id-ID'); }, formatNum(n) { return Number(n).toLocaleString('id-ID'); },

View File

@@ -95,17 +95,30 @@
<button type="button" @click="removeItem(index)" <button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button> class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button>
</div> </div>
{{-- Taxable checkbox --}} {{-- Taxable and Discount checkboxes --}}
<div class="mt-2 flex items-center gap-2"> <div class="mt-2 flex items-center gap-4">
<input type="hidden" :name="`items[${index}][is_taxable]`" value="0"> <div class="flex items-center gap-2">
<input type="checkbox" :name="`items[${index}][is_taxable]`" <input type="hidden" :name="`items[${index}][is_taxable]`" value="0">
:id="`taxable_${index}`" <input type="checkbox" :name="`items[${index}][is_taxable]`"
x-model="item.is_taxable" @change="recalc()" :id="`taxable_${index}`"
value="1" x-model="item.is_taxable" @change="recalc()"
class="w-3.5 h-3.5 accent-pink-400"> value="1"
<label :for="`taxable_${index}`" class="text-xs text-gray-500 cursor-pointer select-none"> class="w-3.5 h-3.5 accent-pink-400">
Taxable <label :for="`taxable_${index}`" class="text-xs text-gray-500 cursor-pointer select-none">
</label> Taxable
</label>
</div>
<div class="flex items-center gap-2" x-show="addDiscount">
<input type="hidden" :name="`items[${index}][is_discountable]`" value="0">
<input type="checkbox" :name="`items[${index}][is_discountable]`"
:id="`discountable_${index}`"
x-model="item.is_discountable" @change="recalc()"
value="1"
class="w-3.5 h-3.5 accent-pink-400">
<label :for="`discountable_${index}`" class="text-xs text-gray-500 cursor-pointer select-none">
Discount
</label>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -114,10 +127,26 @@
{{-- Totals --}} {{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5"> <div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="mb-3 border-b border-pink-100 pb-3 flex flex-col sm:flex-row sm:items-center gap-y-2 gap-x-4 min-h-[32px]">
<label class="flex items-center gap-2 cursor-pointer shrink-0 w-max">
<input type="checkbox" x-model="addDiscount" @change="toggleDiscount()" class="w-4 h-4 accent-pink-400">
<span class="text-sm font-bold text-gray-700">Add Discount</span>
</label>
<div x-show="addDiscount" class="flex items-center gap-3 flex-1 overflow-hidden" style="display: none;">
<input type="range" name="discount_rate" min="5" max="90" step="5" x-model.number="discountRate" @input="recalc()" class="w-32 sm:w-48 accent-pink-500 h-2 bg-pink-100 rounded-lg appearance-none cursor-pointer shrink-0 hover:accent-pink-600 transition-all">
<span class="font-bold text-pink-600 text-sm shrink-0 w-8 text-right"><span x-text="discountRate"></span>%</span>
<span class="text-xs text-pink-500 font-medium tracking-wide truncate border-l border-pink-200 pl-3" x-text="getDiscountLabel()"></span>
</div>
</div>
<div class="flex justify-between text-sm py-1"> <div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal (all items)</span> <span class="text-gray-500">Subtotal (all items)</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span> <span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
</div> </div>
<div class="flex justify-between text-sm py-1 text-pink-500" x-show="addDiscount && discountAmount > 0">
<span>Discount (<span x-text="discountRate"></span>%)</span>
<span class="font-semibold">- Rp <span x-text="formatNum(discountAmount)"></span></span>
</div>
<div class="flex justify-between text-sm py-1 text-amber-600"> <div class="flex justify-between text-sm py-1 text-amber-600">
<span>Tax (<span x-text="taxRate"></span>% — paid by client)</span> <span>Tax (<span x-text="taxRate"></span>% — paid by client)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span> <span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
@@ -223,24 +252,72 @@
function invoiceEditForm() { function invoiceEditForm() {
@php @php
$itemsJson = $invoice->items->map(function($i) { $itemsJson = $invoice->items->map(function($i) {
return ['description' => $i->description, 'quantity' => $i->quantity, 'unit_price' => (float)$i->unit_price, 'is_taxable' => (bool)$i->is_taxable]; return ['description' => $i->description, 'quantity' => $i->quantity, 'unit_price' => (float)$i->unit_price, 'is_taxable' => (bool)$i->is_taxable, 'is_discountable' => (bool)$i->is_discountable];
}); });
@endphp @endphp
return { return {
showCustomFields: {{ old('use_custom_fields', ($invoice->bank_name || $invoice->signature_image || $invoice->signer_name || $invoice->signer_title) ? true : false) ? 'true' : 'false' }}, showCustomFields: {{ old('use_custom_fields', ($invoice->bank_name || $invoice->signature_image || $invoice->signer_name || $invoice->signer_title) ? true : false) ? 'true' : 'false' }},
addDiscount: {{ $invoice->discount_rate > 0 ? 'true' : 'false' }},
discountRate: {{ $invoice->discount_rate ?: 5 }},
items: @json($itemsJson), items: @json($itemsJson),
taxRate: {{ $invoice->tax_rate }}, taxRate: {{ $invoice->tax_rate }},
get subtotal() { get subtotal() {
return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
}, },
get discountAmount() {
if (!this.addDiscount) return 0;
const discountable = this.items
.filter(i => i.is_discountable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0);
return Math.round(discountable * (this.discountRate / 100));
},
get totalSubtotal() {
return this.subtotal - this.discountAmount;
},
get taxAmount() { get taxAmount() {
const taxable = this.items const taxable = this.items
.filter(i => i.is_taxable) .filter(i => i.is_taxable)
.reduce((s, i) => s + (i.quantity * i.unit_price), 0); .reduce((s, i) => {
let itemTotal = i.quantity * i.unit_price;
if (this.addDiscount && i.is_discountable) {
itemTotal -= itemTotal * (this.discountRate / 100);
}
return s + itemTotal;
}, 0);
return Math.round(taxable * this.taxRate / 100); return Math.round(taxable * this.taxRate / 100);
}, },
get total() { return this.subtotal; }, get total() { return this.totalSubtotal; }, // grand total = pre-tax
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true }); }, getDiscountLabel() {
const labels = {
5: 'Small thank-you discount 🙏',
10: 'Appreciation bonus ✨',
15: 'Loyalty perk unlocked 🤝',
20: 'Client appreciation deal 💼',
25: 'Special partner discount ⭐',
30: 'Valued client offer 🎯',
35: 'Priority client savings 📌',
40: 'Strong appreciation rate 💎',
45: 'Preferred client pricing 🏅',
50: 'Half-off partner special 🎉',
55: 'Premium loyalty reward 🧩',
60: 'Major appreciation offer 🚀',
65: 'VIP-level savings 🏆',
70: 'Elite client advantage 👑',
75: 'Strategic partner bonus 🤝✨',
80: 'Executive-tier discount 💼🔥',
85: 'Signature client reward 🖋️',
90: 'Ultimate partnership rate 🌟'
};
return labels[this.discountRate] || '';
},
toggleDiscount() {
if (this.addDiscount) {
this.items.forEach(i => i.is_discountable = true);
} else {
this.items.forEach(i => i.is_discountable = false);
}
},
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0, is_taxable: true, is_discountable: this.addDiscount }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); }, removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() {}, recalc() {},
formatNum(n) { return Number(n).toLocaleString('id-ID'); }, formatNum(n) { return Number(n).toLocaleString('id-ID'); },

View File

@@ -89,6 +89,8 @@
class="text-xs text-pink-500 hover:underline">View</a> class="text-xs text-pink-500 hover:underline">View</a>
<a href="{{ route('invoices.pdf', $invoice) }}" <a href="{{ route('invoices.pdf', $invoice) }}"
class="text-xs text-pink-500 hover:underline" target="_blank">PDF</a> class="text-xs text-pink-500 hover:underline" target="_blank">PDF</a>
<a href="{{ route('invoices.pdf-legacy', $invoice) }}"
class="text-xs text-pink-500 hover:underline" target="_blank">PDF (Legacy)</a>
<a href="{{ route('invoices.edit', $invoice) }}" <a href="{{ route('invoices.edit', $invoice) }}"
class="text-xs text-gray-500 hover:underline">Edit</a> class="text-xs text-gray-500 hover:underline">Edit</a>
{{-- Share button disabled --}} {{-- Share button disabled --}}

View File

@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Helvetica', Arial, sans-serif;
color: #000;
font-size: 11px;
padding: 40px;
}
.header-wrap {
margin-bottom: 40px;
}
.title-block {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.title-line {
flex-grow: 1;
height: 1px;
background: #000;
margin-right: 20px;
}
.title {
font-size: 32px;
letter-spacing: 12px;
font-weight: 300;
}
.info-table {
width: 100%;
margin-bottom: 30px;
}
.info-table td {
vertical-align: top;
}
.label {
font-size: 9px;
color: #666;
text-transform: uppercase;
font-weight: bold;
margin-bottom: 5px;
letter-spacing: 1px;
}
.val-bold {
font-weight: bold;
font-size: 13px;
margin-bottom: 4px;
}
.val {
font-size: 11px;
color: #444;
line-height: 1.4;
}
.details-table {
border-collapse: collapse;
float: right;
}
.details-table td {
padding: 3px 0;
font-size: 11px;
}
.details-table .lbl {
color: #666;
text-align: right;
padding-right: 15px;
font-weight: bold;
width: 80px;
}
.details-table .val-dt {
font-weight: bold;
text-align: left;
}
.items {
width: 100%;
border-collapse: collapse;
margin-bottom: 10px;
}
.items th,
.items td {
padding: 8px 0;
font-size: 11px;
}
.items th {
text-align: left;
text-transform: uppercase;
font-weight: bold;
border-top: 1px solid #000;
border-bottom: 1px solid #000;
font-size: 9px;
letter-spacing: 1px;
color: #333;
}
.items td {
border-bottom: 1px solid #ddd;
}
.items tr:last-child td {
border-bottom: 1px solid #000;
}
.items .td-right {
text-align: right;
}
.items .td-center {
text-align: center;
}
.totals-wrapper {
width: 100%;
margin-bottom: 40px;
}
.totals-wrapper::after {
content: "";
display: table;
clear: both;
}
.totals {
float: right;
border-collapse: collapse;
min-width: 250px;
}
.totals td {
padding: 3px 0;
font-size: 11px;
}
.totals .lbl {
text-align: right;
padding-right: 35px;
letter-spacing: 1px;
font-size: 9px;
font-weight: bold;
}
.totals .val {
text-align: right;
font-weight: bold;
}
.bank-info {
margin-bottom: 60px;
}
.bank-info .label {
margin-bottom: 15px;
}
.bank-info p {
font-size: 11px;
line-height: 1.6;
color: #444;
}
.footer {
width: 100%;
text-align: right;
}
.signature {
display: inline-block;
text-align: center;
width: 200px;
}
.sig-date {
font-size: 11px;
color: #444;
margin-bottom: 15px;
}
.sig-img {
max-height: 60px;
max-width: 180px;
object-fit: contain;
margin-bottom: 15px;
}
.sig-name {
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 3px;
}
.sig-title {
font-size: 9px;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
}
</style>
</head>
<body>
<table style="width:100%; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="width:50%; vertical-align:middle; padding-right:20px;">
<div style="height:1px; background:#000; width:100%;"></div>
</td>
<td style="vertical-align:middle; text-align:right;">
<div class="title">I N V O I C E</div>
</td>
</tr>
</table>
<table class="info-table" cellpadding="0" cellspacing="0">
<tr>
<td style="width:50%; padding-right:20px;">
<div class="label">BILLING TO:</div>
@if($invoice->client)
<div class="val-bold">{{ $invoice->client->company }}</div>
<!-- @if($invoice->client->company)
<div class="val">{{ $invoice->client->company }}</div>@endif -->
@if($invoice->client->full_address)
<div class="val">{!! nl2br(e($invoice->client->full_address)) !!}</div>@endif
@else
<div class="val">—</div>
@endif
</td>
<td style="width:50%; text-align:right;">
<table class="details-table" align="right" cellpadding="0" cellspacing="0">
<tr>
<td class="lbl">Invoice No:</td>
<td class="val-dt">{{ $invoice->invoice_number }}</td>
</tr>
<tr>
<td class="lbl">Date:</td>
<td class="val-dt">{{ $invoice->issue_date->format('d.m.Y') }}</td>
</tr>
<tr>
<td class="lbl">Due Date:</td>
<td class="val-dt">{{ $invoice->due_date->format('d.m.Y') }}</td>
</tr>
</table>
</td>
</tr>
</table>
<div style="margin-bottom: 30px;">
<div class="label">FROM:</div>
<div class="val-bold">
{{ $invoiceSettings->header_name ?: 'Cathy' }}
@if($invoiceSettings->header_title)
<span style="font-weight: normal; color: #444;"> ( {{ $invoiceSettings->header_title }} )</span>
@endif
</div>
</div>
<table class="items" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th style="width:45%;">DESCRIPTION</th>
<th class="td-right" style="width:25%;">PRICE</th>
<th class="td-center" style="width:10%;">QTY</th>
<th class="td-right" style="width:20%;">TOTAL</th>
</tr>
</thead>
<tbody>
@foreach($invoice->items as $item)
<tr>
<td>
{{ $item->description }}
@if(!$item->is_taxable)
<span
style="margin-left:6px; font-size:8px; border:1px solid #999; color:#666; padding:1px 4px; border-radius:3px; text-transform:uppercase; letter-spacing:0.5px;">Non-Taxable</span>
@endif
@if($invoice->discount_rate > 0 && !$item->is_discountable)
<span
style="margin-left:6px; font-size:8px; border:1px solid #999; color:#666; padding:1px 4px; border-radius:3px; text-transform:uppercase; letter-spacing:0.5px;">No
Discount</span>
@endif
</td>
<td class="td-right">Rp {{ number_format($item->unit_price, 0, ',', '.') }}</td>
<td class="td-center">{{ $item->quantity }}</td>
<td class="td-right">Rp {{ number_format($item->total, 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="totals-wrapper">
<table class="totals" cellpadding="0" cellspacing="0">
<tr>
<td class="lbl" style="font-weight: normal;">SUBTOTAL</td>
<td class="val" style="font-weight: normal;">Rp
{{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }}
</td>
</tr>
@if($invoice->discount_amount > 0)
<tr>
<td class="lbl" style="font-weight: normal; color:#666;">DISCOUNT ({{ $invoice->discount_rate }}%)</td>
<td class="val" style="font-weight: normal; color:#666;">- Rp
{{ number_format($invoice->discount_amount, 0, ',', '.') }}
</td>
</tr>
@endif
@if($invoice->tax_rate > 0)
<tr>
<td class="lbl" style="font-weight: normal; color:#666;">TAX ({{ $invoice->tax_rate }}%)</td>
<td class="val" style="font-weight: normal; color:#666;">Rp
{{ number_format($invoice->tax_amount, 0, ',', '.') }}
</td>
</tr>
@endif
<tr>
<td class="lbl" style="padding-top:10px;">GRAND TOTAL</td>
<td class="val" style="padding-top:10px;">{{ $invoice->formatted_total }}</td>
</tr>
</table>
</div>
@if($invoice->bank_name)
<div class="bank-info">
<div class="label"><b>PAYMENT ABOVE SHOULD BE MADE TO:</b></div>
<p>
{{ $invoice->bank_name }}<br>
A/N {{ $invoice->bank_holder }}<br>
{{ $invoice->bank_account }}
@if($invoice->branch_name)<br>KCP: {{ $invoice->branch_name }}@endif
</p>
</div>
@endif
<div class="footer">
@if($invoice->signature_image && file_exists(storage_path('app/public/' . $invoice->signature_image)))
<div class="signature">
<div class="sig-date">Jakarta, {{ $invoice->issue_date->format('d F Y') }}</div>
<img src="{{ storage_path('app/public/' . $invoice->signature_image) }}" class="sig-img">
<div class="sig-name">
{{ $invoice->signer_name ?: ($invoiceSettings->signer_name ?: ($invoiceSettings->header_name ?: 'Signature')) }}
</div>
<div class="sig-title">
{{ $invoice->signer_title ?: ($invoiceSettings->signer_title ?: $invoiceSettings->header_title) }}
</div>
</div>
@endif
</div>
</body>
</html>

View File

@@ -317,6 +317,10 @@
<span <span
style="margin-left:6px; font-size:10px; background:#F3F4F6; color:#6B7280; padding:2px 8px; border-radius:10px; font-weight:600;">Non-Taxable</span> style="margin-left:6px; font-size:10px; background:#F3F4F6; color:#6B7280; padding:2px 8px; border-radius:10px; font-weight:600;">Non-Taxable</span>
@endif @endif
@if($invoice->discount_rate > 0 && !$item->is_discountable)
<span
style="margin-left:6px; font-size:10px; background:#FDF2F8; color:#EC4899; padding:2px 8px; border-radius:10px; font-weight:600;">No Discount</span>
@endif
</td> </td>
<td class="td-right">Rp {{ number_format($item->unit_price, 0, ',', '.') }}</td> <td class="td-right">Rp {{ number_format($item->unit_price, 0, ',', '.') }}</td>
<td class="td-center">{{ $item->quantity }}</td> <td class="td-center">{{ $item->quantity }}</td>
@@ -332,8 +336,14 @@
<table cellpadding="0" cellspacing="0"> <table cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="color:#6B7280;">Subtotal</td> <td style="color:#6B7280;">Subtotal</td>
<td class="amount">Rp {{ number_format($invoice->subtotal, 0, ',', '.') }}</td> <td class="amount">Rp {{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }}</td>
</tr> </tr>
@if($invoice->discount_amount > 0)
<tr>
<td style="color:#EC4899;">Discount ({{ $invoice->discount_rate }}%)</td>
<td class="amount" style="color:#EC4899;">- Rp {{ number_format($invoice->discount_amount, 0, ',', '.') }}</td>
</tr>
@endif
@if($invoice->tax_rate > 0) @if($invoice->tax_rate > 0)
<tr> <tr>
<td style="color:#D97706;">Tax ({{ $invoice->tax_rate }}% — paid by client)</td> <td style="color:#D97706;">Tax ({{ $invoice->tax_rate }}% — paid by client)</td>

View File

@@ -11,6 +11,9 @@
<a href="{{ route('invoices.pdf', $invoice) }}" target="_blank" <a href="{{ route('invoices.pdf', $invoice) }}" target="_blank"
class="btn-sakura text-sm px-4 py-2.5 rounded-full border border-pink-200 text-pink-500 hover:bg-pink-50">📥 class="btn-sakura text-sm px-4 py-2.5 rounded-full border border-pink-200 text-pink-500 hover:bg-pink-50">📥
Download PDF</a> Download PDF</a>
<a href="{{ route('invoices.pdf-legacy', $invoice) }}" target="_blank"
class="btn-sakura text-sm px-4 py-2.5 rounded-full border border-gray-200 text-gray-500 hover:bg-gray-50">📥
PDF (Legacy)</a>
<form action="{{ route('invoices.destroy', $invoice) }}" method="POST" <form action="{{ route('invoices.destroy', $invoice) }}" method="POST"
onsubmit="return confirm('Delete this invoice?')"> onsubmit="return confirm('Delete this invoice?')">
@csrf @method('DELETE') @csrf @method('DELETE')
@@ -104,6 +107,10 @@
<span <span
class="ml-1.5 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full font-medium">Non-Taxable</span> class="ml-1.5 text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full font-medium">Non-Taxable</span>
@endif @endif
@if($invoice->discount_rate > 0 && !$item->is_discountable)
<span
class="ml-1.5 text-xs bg-pink-50 text-pink-500 px-2 py-0.5 rounded-full font-medium">No Discount</span>
@endif
</td> </td>
<td class="py-3 px-4 text-sm text-right text-gray-600">Rp <td class="py-3 px-4 text-sm text-right text-gray-600">Rp
{{ number_format($item->unit_price, 0, ',', '.') }}</td> {{ number_format($item->unit_price, 0, ',', '.') }}</td>
@@ -120,8 +127,14 @@
<div class="w-72 space-y-2 text-sm"> <div class="w-72 space-y-2 text-sm">
<div class="flex justify-between text-gray-500"> <div class="flex justify-between text-gray-500">
<span>Subtotal</span><span class="font-medium">Rp <span>Subtotal</span><span class="font-medium">Rp
{{ number_format($invoice->subtotal, 0, ',', '.') }}</span> {{ number_format($invoice->subtotal + $invoice->discount_amount, 0, ',', '.') }}</span>
</div> </div>
@if($invoice->discount_amount > 0)
<div class="flex justify-between text-pink-500">
<span>Discount ({{ $invoice->discount_rate }}%)</span>
<span class="font-medium">- Rp {{ number_format($invoice->discount_amount, 0, ',', '.') }}</span>
</div>
@endif
@if($invoice->tax_rate > 0) @if($invoice->tax_rate > 0)
<div class="flex justify-between text-amber-600"> <div class="flex justify-between text-amber-600">
<span>Tax ({{ $invoice->tax_rate }}% — paid by client)</span> <span>Tax ({{ $invoice->tax_rate }}% — paid by client)</span>

View File

@@ -299,6 +299,57 @@
</div> </div>
@endif @endif
@php
$pdfShowcase = array_filter($profile->content_showcase ?? [], function($item) {
return !isset($item['show_on_pdf']) || $item['show_on_pdf'];
});
@endphp
@if(!empty($pdfShowcase))
<div style="page-break-before: always;"></div>
<div class="platform-section" style="margin-top:20px; page-break-inside:avoid;">
<div class="platform-title" style="text-align:center;">Content Showcase</div>
<div style="text-align:center; padding-top:10px;">
@foreach($pdfShowcase as $item)
@php
$isUrl = ($item['file_id'] === 'url');
$file = !$isUrl ? \App\Models\MediaFile::find($item['file_id']) : null;
@endphp
@if($file || $isUrl)
<div style="display:inline-block; width:135px; margin:10px; border:1px solid rgba(0,0,0,0.1); border-radius:8px; overflow:hidden; vertical-align:top; background:#ffffff;">
<div style="height:240px; background:#F9FAFB; border-bottom:1px solid rgba(0,0,0,0.05); position:relative; text-align:center;">
@if(!empty($item['thumbnail_path']) && file_exists(storage_path('app/public/' . $item['thumbnail_path'])))
<a href="{{ $isUrl ? $item['url'] : $file->url }}" target="_blank" style="display:block; text-decoration:none; position:relative;">
<div style="width:100%; height:240px; background-image:url('{{ storage_path('app/public/' . $item['thumbnail_path']) }}'); background-size:cover; background-position:center; background-repeat:no-repeat;"></div>
@if(!$isUrl && $file->is_video)
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2);">
<div style="width:30px; height:30px; background:rgba(255,255,255,0.8); border-radius:50%; text-align:center; line-height:30px; color:{{ $accentHex }}; font-size:16px;">▶</div>
</div>
@endif
</a>
@elseif(!$isUrl && $file->is_image && file_exists(storage_path('app/private/' . $file->path)))
<a href="{{ $file->url }}" target="_blank" style="display:block; text-decoration:none;">
<div style="width:100%; height:240px; background-image:url('{{ storage_path('app/private/' . $file->path) }}'); background-size:cover; background-position:center; background-repeat:no-repeat;"></div>
</a>
@else
<a href="{{ $isUrl ? $item['url'] : $file->url }}" target="_blank" style="display:block; text-decoration:none; color:{{ $accentHex }}; padding-top:90px; font-weight:bold;">
<span style="font-size:30px; display:block;">{{ $isUrl ? '🌐' : ($file->is_video ? '🎬' : '📁') }}</span>
{{ $isUrl ? 'Open Link' : 'View File' }}
</a>
@endif
</div>
<div style="padding:10px; text-align:center;">
<div style="font-weight:700; font-size:11px;">{{ $item['name'] }}</div>
<div style="font-size:9px; color:#9CA3AF; margin-top:4px;">
{{ $isUrl ? 'External Link' : ($file->is_video ? 'Video' : ($file->is_image ? 'Image' : 'Document')) }}
</div>
</div>
</div>
@endif
@endforeach
</div>
</div>
@endif
@if($profile->terms_and_conditions) @if($profile->terms_and_conditions)
<div style="page-break-before: always;"></div> <div style="page-break-before: always;"></div>
<div class="platform-section" style="margin-top:20px; page-break-inside:avoid;"> <div class="platform-section" style="margin-top:20px; page-break-inside:avoid;">

View File

@@ -84,52 +84,52 @@
</div> </div>
<div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
uploading: false, uploading: false,
errorMsg: '', errorMsg: '',
logoUrl: '{{ $profile->logo_path ? route('files.serve-profile-logo') : '' }}', logoUrl: '{{ $profile->logo_path ? route('files.serve-profile-logo') : '' }}',
async pickFile(input) { async pickFile(input) {
if (!input.files.length) return; if (!input.files.length) return;
this.uploading = true; this.uploading = true;
this.errorMsg = ''; this.errorMsg = '';
const fd = new FormData(); const fd = new FormData();
fd.append('logo', input.files[0]); fd.append('logo', input.files[0]);
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
try { try {
const res = await fetch('{{ route('rate-cards.profile.upload-logo', [], false) }}', { const res = await fetch('{{ route('rate-cards.profile.upload-logo', [], false) }}', {
method: 'POST', method: 'POST',
body: fd, body: fd,
headers: { headers: {
'Accept': 'application/json' 'Accept': 'application/json'
}
});
const data = await res.json();
if (res.ok) {
this.logoUrl = data.url + '?t=' + Date.now();
} else {
this.errorMsg = data.message ?? 'Upload failed.';
}
} catch (err) {
console.error(err);
this.errorMsg = 'Network error. Please try again.';
} }
}); this.uploading = false;
const data = await res.json(); input.value = '';
if (res.ok) { },
this.logoUrl = data.url + '?t=' + Date.now();
} else {
this.errorMsg = data.message ?? 'Upload failed.';
}
} catch (err) {
console.error(err);
this.errorMsg = 'Network error. Please try again.';
}
this.uploading = false;
input.value = '';
},
async removeLogo() { async removeLogo() {
if (!confirm('Remove this logo?')) return; if (!confirm('Remove this logo?')) return;
const fd = new FormData(); const fd = new FormData();
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
fd.append('_method', 'DELETE'); fd.append('_method', 'DELETE');
await fetch('{{ route('rate-cards.profile.delete-logo', [], false) }}', { await fetch('{{ route('rate-cards.profile.delete-logo', [], false) }}', {
method: 'POST', method: 'POST',
body: fd, body: fd,
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}); });
this.logoUrl = ''; this.logoUrl = '';
} }
}"> }">
<label class="block text-xs font-semibold text-gray-600 mb-1">Logo / Profile Image</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Logo / Profile Image</label>
<template x-if="logoUrl"> <template x-if="logoUrl">
@@ -149,9 +149,9 @@
<div class="relative"> <div class="relative">
<input type="file" accept="image/*" @change="pickFile($event.target)" class="w-full text-sm text-gray-600 <input type="file" accept="image/*" @change="pickFile($event.target)" class="w-full text-sm text-gray-600
file:mr-3 file:py-2 file:px-4 file:rounded-full file:border-0 file:mr-3 file:py-2 file:px-4 file:rounded-full file:border-0
file:text-xs file:font-semibold file:bg-pink-50 file:text-pink-600 file:text-xs file:font-semibold file:bg-pink-50 file:text-pink-600
hover:file:bg-pink-100 cursor-pointer" :disabled="uploading"> hover:file:bg-pink-100 cursor-pointer" :disabled="uploading">
<span x-show="uploading" class="text-xs text-pink-500 mt-1 block">Uploading…</span> <span x-show="uploading" class="text-xs text-pink-500 mt-1 block">Uploading…</span>
<span x-show="errorMsg" x-text="errorMsg" class="text-xs text-red-500 mt-1 block"></span> <span x-show="errorMsg" x-text="errorMsg" class="text-xs text-red-500 mt-1 block"></span>
</div> </div>
@@ -190,25 +190,25 @@
</div> </div>
</div> </div>
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
init() { init() {
var quill = new Quill(this.$refs.editor, { var quill = new Quill(this.$refs.editor, {
theme: 'snow', theme: 'snow',
placeholder: 'e.g. 1. 50% down payment required. 2. Rates valid for 30 days.', placeholder: 'e.g. 1. 50% down payment required. 2. Rates valid for 30 days.',
modules: { modules: {
toolbar: [ toolbar: [
[{ 'size': ['small', false, 'large', 'huge'] }], [{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }], [{ 'color': [] }, { 'background': [] }],
[{ 'list': 'ordered'}, { 'list': 'bullet' }], [{ 'list': 'ordered'}, { 'list': 'bullet' }],
['clean'] ['clean']
] ]
}
});
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
} }
}); }">
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
}
}">
<label class="block text-xs font-semibold text-gray-600 mb-1">Terms & Conditions</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Terms & Conditions</label>
<input type="hidden" name="terms_and_conditions" x-ref="input" <input type="hidden" name="terms_and_conditions" x-ref="input"
value="{{ old('terms_and_conditions', $profile->terms_and_conditions) }}"> value="{{ old('terms_and_conditions', $profile->terms_and_conditions) }}">
@@ -219,41 +219,41 @@
</div> </div>
</div> </div>
<div class="sm:col-span-2" x-data="{ <div class="sm:col-span-2" x-data="{
init() { init() {
var quill = new Quill(this.$refs.editor, { var quill = new Quill(this.$refs.editor, {
theme: 'snow', theme: 'snow',
placeholder: 'Customize your Contact Person Page formatting...', placeholder: 'Customize your Contact Person Page formatting...',
modules: { modules: {
toolbar: [ toolbar: [
[{ 'size': ['small', false, 'large', 'huge'] }], [{ 'size': ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'], ['bold', 'italic', 'underline', 'strike'],
[{ 'color': [] }, { 'background': [] }], [{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }], [{ 'align': [] }],
['clean'] ['clean']
] ]
}
});
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
} }
}); }">
quill.on('text-change', () => {
this.$refs.input.value = quill.root.innerHTML;
});
}
}">
<label class="block text-xs font-semibold text-gray-600 mb-1">Contact Person Page (PDF)</label> <label class="block text-xs font-semibold text-gray-600 mb-1">Contact Person Page (PDF)</label>
@php @php
$defaultContactHtml = '<div style="text-align: center; margin-top: 40px;"> $defaultContactHtml = '<div style="text-align: center; margin-top: 40px;">
<h1 style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; letter-spacing: 4px; color: #111827; margin-bottom: 30px; text-transform: uppercase;">CONTACT PERSON</h1>'; <h1 style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; letter-spacing: 4px; color: #111827; margin-bottom: 30px; text-transform: uppercase;">CONTACT PERSON</h1>';
if ($profile->whatsapp) { if ($profile->whatsapp) {
$defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 36px; font-weight: 900; color: #14b8a6; margin-bottom: 5px;">' . $profile->whatsapp . '</div> $defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 36px; font-weight: 900; color: #14b8a6; margin-bottom: 5px;">' . $profile->whatsapp . '</div>
<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 20px; font-weight: 800; color: #14b8a6; margin-bottom: 30px; letter-spacing: 2px;">(WHATS APP)</div>'; <div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 20px; font-weight: 800; color: #14b8a6; margin-bottom: 30px; letter-spacing: 2px;">(WHATS APP)</div>';
} }
if ($profile->email) { if ($profile->email) {
$defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 14px; font-weight: 800; color: #f97316; margin-bottom: 10px; letter-spacing: 2px;">EMAIL</div> $defaultContactHtml .= '<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 14px; font-weight: 800; color: #f97316; margin-bottom: 10px; letter-spacing: 2px;">EMAIL</div>
<div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; color: #ef4444; margin-bottom: 40px; letter-spacing: 2px;">' . strtoupper($profile->email) . '</div>'; <div style="font-family: \'Comic Sans MS\', sans-serif; font-size: 32px; font-weight: 900; color: #ef4444; margin-bottom: 40px; letter-spacing: 2px;">' . strtoupper($profile->email) . '</div>';
} }
$defaultContactHtml .= '<div style="font-size: 11px; font-weight: 900; color: #111827; line-height: 1.5; max-width: 80%; margin: 0 auto; text-transform: uppercase;"> $defaultContactHtml .= '<div style="font-size: 11px; font-weight: 900; color: #111827; line-height: 1.5; max-width: 80%; margin: 0 auto; text-transform: uppercase;">
Kami tidak bertanggung jawab atas segala bentuk kerjasama yg timbul dengan pihak selain dari kontak diatas Kami tidak bertanggung jawab atas segala bentuk kerjasama yg timbul dengan pihak selain dari kontak diatas
</div> </div>
</div>'; </div>';
@endphp @endphp
<input type="hidden" name="contact_person_html" x-ref="input" <input type="hidden" name="contact_person_html" x-ref="input"
value="{{ old('contact_person_html', $profile->contact_person_html) }}"> value="{{ old('contact_person_html', $profile->contact_person_html) }}">
@@ -282,93 +282,232 @@
</div> </div>
</div> </div>
{{-- Past Brands --}} {{-- Past Brands --}}
<div class="cat-card overflow-hidden"> <div class="cat-card overflow-hidden">
<div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50"> <div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50">
<span class="text-xl"></span> <span class="text-xl"></span>
<h2 class="font-bold text-gray-700">Brand Collaborations Logo</h2> <h2 class="font-bold text-gray-700">Brand Collaborations Logo</h2>
</div> </div>
<div class="p-5" x-data="{ <div class="p-5" x-data="{
uploading: false, uploading: false,
brands: {{ Illuminate\Support\Js::from($profile->past_brands ?? []) }}, brands: {{ Illuminate\Support\Js::from($profile->past_brands ?? []) }},
async pickBrand(input) { async pickBrand(input) {
if (!input.files.length) return; if (!input.files.length) return;
this.uploading = true; this.uploading = true;
const fd = new FormData(); const fd = new FormData();
for (let i = 0; i < input.files.length; i++) { for (let i = 0; i < input.files.length; i++) {
fd.set('logo', input.files[i]); fd.set('logo', input.files[i]);
fd.set('_token', document.querySelector('meta[name=csrf-token]').content); fd.set('_token', document.querySelector('meta[name=csrf-token]').content);
try { try {
const res = await fetch('{{ route('rate-cards.profile.upload-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } }); const res = await fetch('{{ route('rate-cards.profile.upload-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } });
const data = await res.json(); const data = await res.json();
if (res.ok) this.brands.push(data.path); if (res.ok) this.brands.push(data.path);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
this.uploading = false; this.uploading = false;
input.value = ''; input.value = '';
}, },
async removeBrand(path, index) { async removeBrand(path, index) {
if(!confirm('Remove this brand?')) return; if(!confirm('Remove this brand?')) return;
const fd = new FormData(); const fd = new FormData();
fd.append('_token', document.querySelector('meta[name=csrf-token]').content); fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
fd.append('_method', 'DELETE'); fd.append('_method', 'DELETE');
fd.append('path', path); fd.append('path', path);
await fetch('{{ route('rate-cards.profile.delete-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: {'Accept': 'application/json'} }); await fetch('{{ route('rate-cards.profile.delete-brand-logo', [], false) }}', { method: 'POST', body: fd, headers: {'Accept': 'application/json'} });
this.brands.splice(index, 1); this.brands.splice(index, 1);
} }
}"> }">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<label class="block text-xs font-semibold text-gray-600">Brand Logos</label> <label class="block text-xs font-semibold text-gray-600">Brand Logos</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 uppercase tracking-widest font-semibold">Display Size on PDF:</span> <span class="text-[10px] text-gray-500 uppercase tracking-widest font-semibold">Display Size on
<select name="brand_logo_size" class="text-xs border-pink-200 rounded-md py-1 pr-8 pl-2 focus:ring-pink-500 focus:border-pink-500 bg-white"> PDF:</span>
<option value="small" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'small' ? 'selected' : '' }}>Small</option> <select name="brand_logo_size"
<option value="medium" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'medium' ? 'selected' : '' }}>Medium</option> class="text-xs border-pink-200 rounded-md py-1 pr-8 pl-2 focus:ring-pink-500 focus:border-pink-500 bg-white">
<option value="large" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'large' ? 'selected' : '' }}>Large</option> <option value="small" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'small' ? 'selected' : '' }}>Small</option>
</select> <option value="medium" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'medium' ? 'selected' : '' }}>Medium</option>
<option value="large" {{ old('brand_logo_size', $profile->brand_logo_size ?? 'medium') == 'large' ? 'selected' : '' }}>Large</option>
</select>
</div>
</div>
<div class="flex flex-wrap gap-4 mb-3">
<template x-for="(path, index) in brands" :key="index">
<div class="relative rounded-2xl border border-pink-100 p-2 flex items-center justify-center bg-white group shrink-0"
style="width: 100px; height: 100px;">
<img :src="'/storage/' + path" class="max-w-full max-h-full object-contain">
<button type="button" @click="removeBrand(path, index)"
class="absolute -bottom-2 -right-2 bg-red-100 text-red-500 rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-200 shadow text-[10px] z-10 transition-colors">🗑️</button>
</div>
</template>
<label
class="rounded-2xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0"
style="width: 100px; height: 100px;" :class="{'opacity-50 pointer-events-none': uploading}">
<span x-show="!uploading" class="text-2xl">+</span>
<span x-show="uploading" class="text-xs">...</span>
<input type="file" multiple accept="image/*" class="hidden" @change="pickBrand($event.target)">
</label>
</div>
<p class="text-xs text-gray-400 mt-1">Upload brand logos you've worked with. They will be displayed on
your rate
cards.</p>
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-4 mb-3">
<template x-for="(path, index) in brands" :key="index">
<div
class="relative rounded-2xl border border-pink-100 p-2 flex items-center justify-center bg-white group shrink-0"
style="width: 100px; height: 100px;">
<img :src="'/storage/' + path" class="max-w-full max-h-full object-contain">
<button type="button" @click="removeBrand(path, index)"
class="absolute -bottom-2 -right-2 bg-red-100 text-red-500 rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-200 shadow text-[10px] z-10 transition-colors">🗑️</button>
</div>
</template>
<label {{-- Content Showcase --}}
class="rounded-2xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0" <div class="cat-card overflow-hidden">
style="width: 100px; height: 100px;" <div class="flex items-center gap-3 px-5 py-4 border-b border-pink-50">
:class="{'opacity-50 pointer-events-none': uploading}"> <span class="text-xl">📺</span>
<span x-show="!uploading" class="text-2xl">+</span> <h2 class="font-bold text-gray-700">Content Showcase</h2>
<span x-show="uploading" class="text-xs">...</span> </div>
<input type="file" multiple accept="image/*" class="hidden" @change="pickBrand($event.target)"> <div class="p-5" x-data="{
</label> items: {{ Illuminate\Support\Js::from(old('content_showcase', $profile->content_showcase ?? [])) }}.map(i => ({ file_id: i.file_id || '', name: i.name || '', url: i.url || '', thumbnail_path: i.thumbnail_path || '', show_on_public: i.show_on_public ?? true, show_on_pdf: i.show_on_pdf ?? true, uploading: false })),
addItem() {
this.items.push({ file_id: '', name: '', url: '', thumbnail_path: '', show_on_public: true, show_on_pdf: true, uploading: false });
},
removeItem(index) {
this.items.splice(index, 1);
},
async uploadThumb(input, item) {
if (!input.files.length) return;
item.uploading = true;
const fd = new FormData();
fd.append('thumbnail', input.files[0]);
fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
try {
const res = await fetch('{{ route('rate-cards.profile.upload-showcase-thumbnail', [], false) }}', { method: 'POST', body: fd, headers: { 'Accept': 'application/json' } });
const data = await res.json();
if (res.ok) {
item.thumbnail_path = data.path;
} else {
alert(data.message || 'Upload failed');
}
} catch (e) {
console.error(e);
alert('Network Error. Please try again.');
}
item.uploading = false;
input.value = '';
}
}">
<template x-for="(item, index) in items" :key="index">
<div class="flex flex-col gap-3 mb-4 bg-pink-50/30 p-3 rounded-2xl border border-pink-100 relative">
<div class="flex flex-col sm:flex-row gap-3 items-start w-full">
<div class="flex-1 w-full">
<label class="block text-[10px] font-semibold text-gray-600 mb-1">Select File <span
class="text-pink-400">*</span></label>
<select :name="'content_showcase['+index+'][file_id]'" x-model="item.file_id" required
class="input-soft w-full py-1.5 text-xs">
<option value="">-- Choose a File --</option>
<option value="url" class="font-bold text-pink-600">+ External Link</option>
@foreach($mediaFiles as $file)
<option value="{{ $file->id }}">{{ $file->name }}</option>
@endforeach
</select>
</div>
<div class="flex-1 w-full">
<label class="block text-[10px] font-semibold text-gray-600 mb-1">Custom Display Name
<span class="text-pink-400">*</span></label>
<input type="text" :name="'content_showcase['+index+'][name]'" x-model="item.name"
required class="input-soft w-full py-1.5 text-xs" placeholder="e.g. Viral Vlog">
</div>
<div class="shrink-0 flex items-end pt-5">
<button type="button" @click="removeItem(index)"
class="bg-red-100 text-red-500 rounded-lg px-3 py-1.5 hover:bg-red-200 transition-colors text-[10px] font-semibold">
Remove
</button>
</div>
</div>
<template x-if="item.file_id">
<div class="w-full">
<div class="w-full border-t border-pink-100 pt-3 flex flex-wrap gap-3 items-center">
<div class="shrink-0 flex items-center justify-center">
<input type="hidden" :name="'content_showcase['+index+'][thumbnail_path]'"
x-model="item.thumbnail_path">
<template x-if="item.thumbnail_path">
<div class="relative rounded-xl border border-pink-100 p-1 flex items-center justify-center bg-white group shrink-0"
style="width: 54px; height: 54px;">
<img :src="'/storage/' + item.thumbnail_path"
class="max-w-full max-h-full object-contain rounded">
<button type="button" @click="item.thumbnail_path = ''"
class="absolute -top-2 -right-2 bg-red-100 text-red-500 rounded-full w-5 h-5 flex items-center justify-center hover:bg-red-200 shadow text-[9px] z-10 transition-colors">🗑️</button>
</div>
</template>
<template x-if="!item.thumbnail_path">
<label
class="rounded-xl border-2 border-dashed border-pink-200 flex items-center justify-center text-pink-300 hover:bg-pink-50 hover:text-pink-400 cursor-pointer transition-colors shrink-0"
style="width: 54px; height: 54px;"
:class="{'opacity-50 pointer-events-none': item.uploading}"
title="Upload Thumbnail (Optional)">
<span x-show="!item.uploading"
class="text-xl font-light leading-none mb-1">+</span>
<span x-show="item.uploading" class="text-[10px]">...</span>
<input type="file" accept="image/*" class="hidden"
@change="uploadThumb($event.target, item)">
</label>
</template>
</div>
<template x-if="item.file_id === 'url'">
<div class="flex-1 min-w-[200px]">
<input type="url" :name="'content_showcase['+index+'][url]'" x-model="item.url"
class="input-soft w-full py-2 px-3 text-xs"
placeholder="URL (https://instagram.com/p/...)" required>
</div>
</template>
<template x-if="item.file_id !== 'url'">
<div class="flex-1 text-xs text-gray-400">
Uploaded Thumbnail: Use this to override the default display icon for videos and documents.
</div>
</template>
</div>
<div class="w-full border-t border-pink-100 pt-3 flex flex-wrap gap-4 items-center mt-2">
<label class="flex items-center gap-2 cursor-pointer text-[11px] font-semibold text-gray-600">
<input type="hidden" :name="'content_showcase['+index+'][show_on_public]'" value="0">
<input type="checkbox" :name="'content_showcase['+index+'][show_on_public]'" value="1" x-model="item.show_on_public" class="w-4 h-4 accent-pink-400 rounded cursor-pointer shrink-0">
Show on Public Page
</label>
<label class="flex items-center gap-2 cursor-pointer text-[11px] font-semibold text-gray-600">
<input type="hidden" :name="'content_showcase['+index+'][show_on_pdf]'" value="0">
<input type="checkbox" :name="'content_showcase['+index+'][show_on_pdf]'" value="1" x-model="item.show_on_pdf" class="w-4 h-4 accent-pink-400 rounded cursor-pointer shrink-0">
Show on PDF
</label>
</div>
</div>
</template>
</div>
</template>
<button type="button" @click="addItem()"
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-pink-500 hover:bg-pink-50 font-semibold bg-white mb-2 shadow-sm">
+ Add Content to Showcase
</button>
<p class="text-xs text-gray-400 mt-1">Select files you've uploaded in the Files page to feature them
directly on your public rate card.</p>
</div>
</div> </div>
<p class="text-xs text-gray-400 mt-1">Upload brand logos you've worked with. They will be displayed on your rate
cards.</p>
</div>
</div>
{{-- Actions --}} {{-- Actions --}}
<div class="flex flex-wrap items-center gap-2 w-full"> <div class="flex flex-wrap items-center gap-2 w-full">
<button type="submit" class="btn-sakura text-xs sm:text-sm shrink-0 px-4 py-2 sm:px-6 sm:py-2.5">💾 Save Profile</button> <button type="submit" class="btn-sakura text-xs sm:text-sm shrink-0 px-4 py-2 sm:px-6 sm:py-2.5">💾 Save
<a href="{{ route('rate-cards.public') }}" target="_blank" Profile</button>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.public') }}" target="_blank"
👁️ Preview class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50 shadow-sm shrink-0">
</a> 👁️ Preview
<a href="{{ route('rate-cards.pdf') }}" </a>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-blue-200 text-blue-600 hover:bg-blue-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.pdf') }}"
📄 Download PDF class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-blue-200 text-blue-600 hover:bg-blue-50 shadow-sm shrink-0">
</a> 📄 Download PDF
<a href="{{ route('rate-cards.index') }}" </a>
class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-gray-200 text-gray-600 hover:bg-gray-50 shadow-sm shrink-0"> <a href="{{ route('rate-cards.index') }}"
← Back class="bg-white text-xs sm:text-sm px-3 py-1.5 sm:px-4 sm:py-2 rounded-full border border-gray-200 text-gray-600 hover:bg-gray-50 shadow-sm shrink-0">
</a> ← Back
</div> </a>
</form> </div>
</form>
</div> </div>
@endsection @endsection

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Card — {{ $profile->owner_name }}</title> <title>Rate Card — {{ $profile->owner_name }}</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -110,6 +111,63 @@
gap: 14px; gap: 14px;
} }
.carousel-wrapper {
position: relative;
}
.carousel-track {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 14px;
padding-bottom: 10px;
scrollbar-width: none;
}
.carousel-track::-webkit-scrollbar {
display: none;
}
.carousel-slide {
flex: 0 0 220px;
scroll-snap-align: start;
}
.carousel-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
background: white;
border: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
color:
{{ $profile->accent_color }}
;
font-size: 20px;
font-weight: 900;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
}
.carousel-btn:hover {
background: #fdfdfd;
box-shadow: 0 6px 14px rgba(0,0,0,0.15);
}
.carousel-btn.prev {
left: -20px;
}
.carousel-btn.next {
right: -20px;
}
.card { .card {
background: white; background: white;
border-radius: 24px; border-radius: 24px;
@@ -122,6 +180,17 @@
22; 22;
} }
.popup-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.service { .service {
font-weight: 700; font-weight: 700;
color: #1F2937; color: #1F2937;
@@ -240,7 +309,7 @@
</style> </style>
</head> </head>
<body> <body x-data="{ popupOpen: false, popupImage: '' }">
<div class="container"> <div class="container">
<div class="header"> <div class="header">
@if($profile->logo_path) @if($profile->logo_path)
@@ -328,9 +397,114 @@
</div> </div>
@endif @endif
@php
$publicShowcase = array_filter($profile->content_showcase ?? [], function($item) {
return !isset($item['show_on_public']) || $item['show_on_public'];
});
@endphp
@if(!empty($publicShowcase))
<div class="platform-section" style="margin-top:40px;">
<div class="platform-title" style="justify-content:center;">Content Showcase</div>
@php
$isCarousel = count($publicShowcase) >= 3;
$renderShowcase = $isCarousel ? array_merge($publicShowcase, $publicShowcase) : $publicShowcase;
@endphp
@if($isCarousel)
<div class="carousel-wrapper" x-data="{
scrollTimeout: null,
scrollPrev() {
const slider = this.$refs.slider;
const el = slider.lastElementChild;
const dist = el.offsetWidth + 14;
slider.insertBefore(el, slider.firstElementChild);
slider.scrollLeft += dist;
void slider.offsetWidth;
slider.scrollBy({left: -dist, behavior: 'smooth'});
},
scrollNext() {
const slider = this.$refs.slider;
const el = slider.firstElementChild;
const dist = el.offsetWidth + 14;
slider.scrollBy({left: dist, behavior: 'smooth'});
if (this.scrollTimeout) clearTimeout(this.scrollTimeout);
this.scrollTimeout = setTimeout(() => {
while (slider.scrollLeft >= slider.firstElementChild.offsetWidth + 10) {
const first = slider.firstElementChild;
const moveDist = first.offsetWidth + 14;
slider.appendChild(first);
slider.scrollLeft -= moveDist;
}
}, 400);
}
}">
<button type="button" class="carousel-btn prev" @click="scrollPrev()">&#10094;</button>
<div class="carousel-track" x-ref="slider" style="overflow-anchor: none;">
@else
<div class="cards">
@endif
@foreach($renderShowcase as $item)
@php
$isUrl = ($item['file_id'] === 'url');
$file = !$isUrl ? \App\Models\MediaFile::find($item['file_id']) : null;
@endphp
@if($file || $isUrl)
<div class="card {{ $isCarousel ? 'carousel-slide' : '' }}" style="padding:0; overflow:hidden; display:flex; flex-direction:column;">
<div style="flex:1; background:#F9FAFB; display:flex; align-items:center; justify-content:center; position:relative;">
@if(!empty($item['thumbnail_path']))
@if(!$isUrl && $file->is_image)
<div style="width:100%; aspect-ratio:9/16; cursor:pointer; position:relative;" @click="popupImage = '{{ $file->url }}'; popupOpen = true;">
<img src="{{ asset('storage/' . $item['thumbnail_path']) }}" style="width:100%; height:100%; object-fit:cover; display:block;">
</div>
@else
<a href="{{ $isUrl ? ($item['url'] ?? '#') : $file->url }}" target="_blank" style="display:block; width:100%; aspect-ratio:9/16; position:relative;">
<img src="{{ asset('storage/' . $item['thumbnail_path']) }}" style="width:100%; height:100%; object-fit:cover; display:block;">
@if(!$isUrl && $file->is_video)
<div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.2);">
<div style="width:48px; height:48px; background:rgba(255,255,255,0.8); border-radius:50%; display:flex; align-items:center; justify-content:center; color:{{ $profile->accent_color }}; font-size:24px;">▶</div>
</div>
@endif
</a>
@endif
@elseif($isUrl)
<div style="aspect-ratio:9/16; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:20px; text-align:center;">
<span style="font-size:48px;">🌐</span>
<a href="{{ $item['url'] ?? '#' }}" target="_blank" style="margin-top:10px; font-size:12px; color:{{ $profile->accent_color }}; text-decoration:none; font-weight:700;">Open Link</a>
</div>
@else
@if($file->is_image)
<div style="width:100%; aspect-ratio:9/16; cursor:pointer;" @click="popupImage = '{{ $file->url }}'; popupOpen = true;">
<img src="{{ $file->url }}" style="width:100%; height:100%; object-fit:cover; display:block;">
</div>
@elseif($file->is_video)
<video src="{{ $file->url }}" style="width:100%; aspect-ratio:9/16; object-fit:cover; display:block;" controls></video>
@else
<div style="aspect-ratio:9/16; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:20px; text-align:center;">
<span style="font-size:48px;">{{ $file->icon }}</span>
<a href="{{ $file->url }}" target="_blank" style="margin-top:10px; font-size:12px; color:{{ $profile->accent_color }}; text-decoration:none; font-weight:700;">View File</a>
</div>
@endif
@endif
</div>
<div style="padding:16px;">
<div class="service" style="margin-bottom:0; text-align:center; font-weight:700;">{{ $item['name'] }}</div>
</div>
</div>
@endif
@endforeach
@if($isCarousel)
</div>
<button type="button" class="carousel-btn next" @click="scrollNext()">&#10095;</button>
</div>
@else
</div>
@endif
</div>
@endif
@if($profile->terms_and_conditions) @if($profile->terms_and_conditions)
<div class="platform-section" style="margin-top:40px;"> <div class="platform-section" style="margin-top:40px;">
<div class="platform-title">Terms & Conditions</div> <div class="platform-title" style="justify-content:center;">Terms & Conditions</div>
<div class="card" style="font-size:13px; color:#6B7280; line-height:1.6;"> <div class="card" style="font-size:13px; color:#6B7280; line-height:1.6;">
{!! $profile->terms_and_conditions !!}</div> {!! $profile->terms_and_conditions !!}</div>
</div> </div>
@@ -341,6 +515,13 @@
<!--<br>Generated by Cathy's App · {{ now()->format('Y-M-D') }}--> <!--<br>Generated by Cathy's App · {{ now()->format('Y-M-D') }}-->
</div> </div>
</div> </div>
<!-- Image Popup Modal -->
<div x-show="popupOpen" x-transition.opacity style="display:none;" class="popup-modal">
<div style="position:absolute; inset:0; cursor:pointer;" @click="popupOpen = false"></div>
<img :src="popupImage" style="max-width:100%; max-height:100%; object-fit:contain; position:relative; z-index:10000; border-radius:12px; box-shadow:0 10px 25px rgba(0,0,0,0.5);">
<button @click="popupOpen = false" style="position:absolute; top:20px; right:20px; background:rgba(255,255,255,0.2); border:none; color:white; font-size:30px; width:40px; height:40px; border-radius:50%; cursor:pointer; z-index:10001; display:flex; align-items:center; justify-content:center;">&times;</button>
</div>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@@ -72,6 +72,7 @@ Route::prefix('social-hub')->name('social-hub.')->group(function (): void {
// Financial – Invoices // Financial – Invoices
Route::resource('invoices', InvoiceController::class); Route::resource('invoices', InvoiceController::class);
Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'pdf'])->name('invoices.pdf'); Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'pdf'])->name('invoices.pdf');
Route::get('/invoices/{invoice}/pdf-legacy', [InvoiceController::class, 'pdfLegacy'])->name('invoices.pdf-legacy');
Route::get('/invoice-settings', [InvoiceSettingsController::class, 'edit'])->name('invoice-settings.edit'); Route::get('/invoice-settings', [InvoiceSettingsController::class, 'edit'])->name('invoice-settings.edit');
Route::post('/invoice-settings', [InvoiceSettingsController::class, 'update'])->name('invoice-settings.update'); 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::post('/invoice-settings/logo', [InvoiceSettingsController::class, 'uploadLogo'])->name('invoice-settings.upload-logo');
@@ -88,6 +89,7 @@ Route::post('/rate-cards/profile/logo', [RateCardController::class, 'uploadLogo'
Route::delete('/rate-cards/profile/logo', [RateCardController::class, 'deleteLogo'])->name('rate-cards.profile.delete-logo'); Route::delete('/rate-cards/profile/logo', [RateCardController::class, 'deleteLogo'])->name('rate-cards.profile.delete-logo');
Route::post('/rate-cards/profile/brand-logo', [RateCardController::class, 'uploadBrandLogo'])->name('rate-cards.profile.upload-brand-logo'); Route::post('/rate-cards/profile/brand-logo', [RateCardController::class, 'uploadBrandLogo'])->name('rate-cards.profile.upload-brand-logo');
Route::delete('/rate-cards/profile/brand-logo', [RateCardController::class, 'deleteBrandLogo'])->name('rate-cards.profile.delete-brand-logo'); Route::delete('/rate-cards/profile/brand-logo', [RateCardController::class, 'deleteBrandLogo'])->name('rate-cards.profile.delete-brand-logo');
Route::post('/rate-cards/profile/content-thumbnail', [RateCardController::class, 'uploadShowcaseThumbnail'])->name('rate-cards.profile.upload-showcase-thumbnail');
Route::get('/files/profile-logo', [RateCardController::class, 'serveProfileLogo'])->name('files.serve-profile-logo'); Route::get('/files/profile-logo', [RateCardController::class, 'serveProfileLogo'])->name('files.serve-profile-logo');
Route::resource('rate-cards', RateCardController::class)->except(['show']); Route::resource('rate-cards', RateCardController::class)->except(['show']);