Add contract management and invoice settings pages

- Created a new Blade view for managing brand contracts with forms for adding and editing contracts, including client and category selection.
- Implemented success message notifications for contract actions.
- Added a new Blade view for invoice settings, allowing customization of invoice number format, header branding, and default values for new invoices.
- Introduced a new PDF view for generating rate cards with customizable styles based on user profile settings.
- Developed a profile management page for rate cards, enabling users to update their identity, appearance, and visibility settings.
This commit is contained in:
I-Fun
2026-04-01 21:47:34 +07:00
parent d3c35ad730
commit 22f03ccca7
48 changed files with 2415 additions and 296 deletions

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BrandContract extends Model
{
protected $fillable = [
'title', 'client_id', 'category_id',
'start_date', 'end_date', 'notes', 'is_active',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_active' => 'boolean',
];
// ── Relationships ────────────────────────────────────────────
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ClientCategory::class, 'category_id');
}
// ── Scopes ───────────────────────────────────────────────────
public function scopeActive($query)
{
return $query->where('is_active', true)
->where('start_date', '<=', today())
->where('end_date', '>=', today());
}
// ── Helpers ──────────────────────────────────────────────────
public function isCurrentlyActive(): bool
{
return $this->is_active
&& $this->start_date->lte(today())
&& $this->end_date->gte(today());
}
public function getDaysRemainingAttribute(): int
{
return (int) max(0, today()->diffInDays($this->end_date, false));
}
public function getStatusLabelAttribute(): string
{
if (! $this->is_active) return 'Inactive';
if ($this->start_date->gt(today())) return 'Upcoming';
if ($this->end_date->lt(today())) return 'Expired';
return 'Active';
}
public function getStatusColorAttribute(): string
{
return match ($this->statusLabel) {
'Active' => 'badge-success',
'Upcoming' => 'badge-info',
'Expired' => 'badge-neutral',
default => 'badge-ghost',
};
}
}

View File

@@ -15,7 +15,7 @@ class ContentBrief extends Model
use HasFactory, SoftDeletes;
protected $fillable = [
'client_id', 'title', 'platform', 'content_type', 'status',
'client_id', 'category_id', 'title', 'platform', 'content_type', 'status',
'hook', 'body', 'cta', 'script', 'caption', 'hashtags',
'target_date', 'posted_date', 'posted_url', 'sort_order', 'notes',
];
@@ -31,6 +31,11 @@ class ContentBrief extends Model
return $this->belongsTo(Client::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ClientCategory::class, 'category_id');
}
public function getPlatformIconAttribute(): string
{
return match($this->platform) {

View File

@@ -15,7 +15,7 @@ class Event extends Model
use HasFactory, SoftDeletes;
protected $fillable = [
'client_id', 'title', 'description', 'event_type',
'client_id', 'category_id', 'title', 'description', 'event_type',
'start_time', 'end_time', 'location', 'is_priority',
'is_all_day', 'status', 'color', 'notes',
];
@@ -33,6 +33,11 @@ class Event extends Model
return $this->belongsTo(Client::class);
}
public function category(): BelongsTo
{
return $this->belongsTo(ClientCategory::class, 'category_id');
}
public function scopeUpcoming($query, int $hours = 24)
{
return $query->where('start_time', '>=', now())

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use App\Enums\InvoiceStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
// InvoiceSettings used for number generation
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -19,7 +20,7 @@ class Invoice extends Model
'client_id', 'invoice_number', 'title', 'description',
'subtotal', 'tax_rate', 'tax_amount', 'total', 'currency',
'status', 'issue_date', 'due_date', 'paid_date', 'notes',
'bank_name', 'bank_account', 'bank_holder',
'bank_name', 'bank_account', 'bank_holder', 'branch_name',
];
protected $casts = [
@@ -45,9 +46,15 @@ class Invoice extends Model
public function recalculate(): void
{
$this->subtotal = $this->items->sum('total');
$this->tax_amount = round($this->subtotal * ($this->tax_rate / 100), 2);
$this->total = $this->subtotal + $this->tax_amount;
// Total of all items (what the creator receives)
$this->subtotal = $this->items->sum('total');
// Tax is calculated only on taxable items, but paid by the client on top
$taxableSubtotal = $this->items->where('is_taxable', true)->sum('total');
$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)
$this->total = $this->subtotal;
$this->save();
}
@@ -58,8 +65,6 @@ class Invoice extends Model
public static function generateNumber(): string
{
$prefix = 'INV-' . now()->format('Ym');
$last = static::where('invoice_number', 'like', $prefix . '%')->count();
return $prefix . '-' . str_pad((string) ($last + 1), 3, '0', STR_PAD_LEFT);
return InvoiceSettings::generateNumber();
}
}

View File

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

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InvoiceSettings extends Model
{
protected $table = 'invoice_settings';
protected $fillable = [
'invoice_prefix',
'invoice_next_number',
'header_name',
'header_title',
'header_logo',
'default_tax_rate',
'default_currency',
'default_service_description',
'default_bank_name',
'default_branch_name',
'default_bank_account',
'default_bank_holder',
'default_notes',
];
protected $casts = [
'invoice_next_number' => 'integer',
'default_tax_rate' => 'decimal:2',
];
/** Always return the single settings row, creating it if absent. */
public static function instance(): self
{
return static::firstOrCreate([], [
'invoice_prefix' => 'INV',
'invoice_next_number' => 1,
'default_tax_rate' => 11,
'default_currency' => 'IDR',
]);
}
/** Generate the next invoice number and increment the counter. */
public static function generateNumber(): string
{
$settings = static::instance();
$prefix = rtrim($settings->invoice_prefix, '-');
$number = str_pad((string) $settings->invoice_next_number, 3, '0', STR_PAD_LEFT);
$settings->increment('invoice_next_number');
return $prefix . '-' . $number;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RateCardProfile extends Model
{
protected $fillable = [
'owner_name', 'tagline', 'whatsapp',
'ig_handle', 'tiktok_handle', 'youtube_handle', 'email',
'logo_path', 'bg_color', 'accent_color', 'text_color',
'custom_footer', 'show_prices', 'access_token',
];
protected $casts = [
'show_prices' => 'boolean',
];
/** Get the singleton profile, creating defaults if needed */
public static function getSingleton(): self
{
return static::firstOrCreate([], [
'owner_name' => config('app.name', 'KOL Manager'),
'bg_color' => '#FFF8F0',
'accent_color' => '#FFB7C5',
'text_color' => '#374151',
'show_prices' => true,
]);
}
public function getWhatsappUrlAttribute(): ?string
{
if (! $this->whatsapp) return null;
$number = preg_replace('/[^0-9]/', '', $this->whatsapp);
return "https://wa.me/{$number}";
}
public function getLogoUrlAttribute(): ?string
{
return $this->logo_path
? route('files.serve-profile-logo')
: null;
}
}