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:
75
app/Models/BrandContract.php
Normal file
75
app/Models/BrandContract.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
54
app/Models/InvoiceSettings.php
Normal file
54
app/Models/InvoiceSettings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
47
app/Models/RateCardProfile.php
Normal file
47
app/Models/RateCardProfile.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user