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,126 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\BrandContract;
use App\Models\Client;
use App\Models\ClientCategory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BrandContractController extends Controller
{
private function categoryTree(): \Illuminate\Support\Collection
{
return ClientCategory::with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
}
public function index(): View
{
$contracts = BrandContract::with(['client', 'category'])
->orderByDesc('end_date')
->get();
$clients = Client::orderBy('name')->get();
$categoryTree = $this->categoryTree();
return view('contracts.index', compact('contracts', 'clients', 'categoryTree'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'required|exists:client_categories,id',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'notes' => 'nullable|string',
]);
$validated['is_active'] = true;
BrandContract::create($validated);
return back()->with('success', '📝 Contract added!');
}
public function update(Request $request, BrandContract $contract): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'required|exists:client_categories,id',
'start_date' => 'required|date',
'end_date' => 'required|date|after_or_equal:start_date',
'notes' => 'nullable|string',
'is_active' => 'boolean',
]);
$validated['is_active'] = $request->boolean('is_active', $contract->is_active);
$contract->update($validated);
return back()->with('success', '📝 Contract updated!');
}
public function destroy(BrandContract $contract): RedirectResponse
{
$contract->delete();
return back()->with('success', '📝 Contract removed.');
}
public function toggle(BrandContract $contract): RedirectResponse
{
$contract->update(['is_active' => ! $contract->is_active]);
return back()->with('success', $contract->is_active ? '✅ Contract activated.' : '⏸️ Contract paused.');
}
/**
* AJAX: check if a category has an active exclusive contract.
* Returns null if clear, or contract details if blocked.
*/
public function check(Request $request): JsonResponse
{
$categoryId = $request->integer('category_id');
$clientId = $request->integer('client_id');
if (! $categoryId) {
return response()->json(null);
}
// Collect the category and its parent chain (so blocking a parent also blocks children)
$category = ClientCategory::find($categoryId);
$checkIds = [$categoryId];
if ($category?->parent_id) {
$checkIds[] = $category->parent_id;
}
$contract = BrandContract::with(['client', 'category'])
->active()
->whereIn('category_id', $checkIds)
->first();
if (! $contract) {
return response()->json(null);
}
// Allow if it's the contracted brand itself
if ($clientId && $contract->client_id && $contract->client_id === $clientId) {
return response()->json(null);
}
return response()->json([
'id' => $contract->id,
'title' => $contract->title,
'client_name' => $contract->client?->name ?? 'Unknown',
'category' => $contract->category?->name ?? 'Unknown',
'end_date' => $contract->end_date->format('d M Y'),
'days_left' => $contract->days_remaining,
]);
}
}

View File

@@ -10,10 +10,18 @@ use Illuminate\Http\Request;
class ClientCategoryController extends Controller
{
public function index()
public function index(): \Illuminate\View\View
{
// Managed inline from the clients page; redirect there
return redirect()->route('clients.index');
$categories = ClientCategory::with('children', 'parent')
->withCount('clients')
->orderBy('sort_order')
->get();
$parents = ClientCategory::whereNull('parent_id')
->orderBy('sort_order')
->get();
return view('categories.index', compact('categories', 'parents'));
}
public function store(Request $request): RedirectResponse

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
use App\Enums\ContentStatus;
use App\Models\Client;
use App\Models\ClientCategory;
use App\Models\ContentBrief;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -14,6 +15,14 @@ use Illuminate\View\View;
class ContentBriefController extends Controller
{
private function categoryTree(): \Illuminate\Support\Collection
{
return ClientCategory::with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
}
public function index(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
@@ -32,9 +41,10 @@ class ContentBriefController extends Controller
public function create(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
return view('content.create', compact('clients', 'statuses'));
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
$categoryTree = $this->categoryTree();
return view('content.create', compact('clients', 'statuses', 'categoryTree'));
}
public function store(Request $request): RedirectResponse
@@ -42,6 +52,7 @@ class ContentBriefController extends Controller
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'nullable|exists:client_categories,id',
'platform' => 'required|in:instagram,tiktok,youtube',
'content_type' => 'required|string|max:50',
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
@@ -69,10 +80,16 @@ class ContentBriefController extends Controller
public function edit(ContentBrief $contentBrief): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
$categoryTree = $this->categoryTree();
$contentBrief->load('client');
return view('content.edit', ['brief' => $contentBrief, 'clients' => $clients, 'statuses' => $statuses]);
return view('content.edit', [
'brief' => $contentBrief,
'clients' => $clients,
'statuses' => $statuses,
'categoryTree' => $categoryTree,
]);
}
public function update(Request $request, ContentBrief $contentBrief): RedirectResponse
@@ -80,6 +97,7 @@ class ContentBriefController extends Controller
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'nullable|exists:client_categories,id',
'platform' => 'required|in:instagram,tiktok,youtube',
'content_type' => 'required|string|max:50',
'status' => 'required|in:drafting,filming,editing,review,posted,archived',

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Models\Client;
use App\Models\ClientCategory;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -14,10 +15,19 @@ use Illuminate\View\View;
class EventController extends Controller
{
private function categoryTree(): \Illuminate\Support\Collection
{
return ClientCategory::with('children')
->whereNull('parent_id')
->orderBy('sort_order')
->get();
}
public function index(): View
{
$events = Event::with('client')->orderBy('start_time')->get();
$clients = Client::where('status', 'active')->orderBy('name')->get();
$events = Event::with('client')->orderBy('start_time')->get();
$clients = Client::where('status', 'active')->orderBy('name')->get();
$categoryTree = $this->categoryTree();
// Format for FullCalendar
$calendarEvents = $events->map(fn (Event $e) => [
@@ -35,7 +45,7 @@ class EventController extends Controller
],
]);
return view('schedule.index', compact('events', 'clients', 'calendarEvents'));
return view('schedule.index', compact('events', 'clients', 'calendarEvents', 'categoryTree'));
}
public function store(Request $request): RedirectResponse
@@ -44,6 +54,7 @@ class EventController extends Controller
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'nullable|exists:client_categories,id',
'event_type' => 'required|in:task,meeting,deadline,posting',
'start_time' => 'required|date',
'end_time' => 'nullable|date|after:start_time',
@@ -65,10 +76,11 @@ class EventController extends Controller
public function edit(Event $event): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = EventStatus::cases();
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = EventStatus::cases();
$categoryTree = $this->categoryTree();
return view('schedule.edit', compact('event', 'clients', 'statuses'));
return view('schedule.edit', compact('event', 'clients', 'statuses', 'categoryTree'));
}
public function update(Request $request, Event $event): RedirectResponse
@@ -77,6 +89,7 @@ class EventController extends Controller
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'client_id' => 'nullable|exists:clients,id',
'category_id' => 'nullable|exists:client_categories,id',
'event_type' => 'required|in:task,meeting,deadline,posting',
'start_time' => 'required|date',
'end_time' => 'nullable|date',

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers;
use App\Enums\InvoiceStatus;
use App\Models\Client;
use App\Models\Invoice;
use App\Models\InvoiceSettings;
use App\Services\InvoiceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -27,9 +28,10 @@ class InvoiceController extends Controller
public function create(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
return view('invoices.create', compact('clients', 'statuses'));
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
$invoiceSettings = InvoiceSettings::instance();
return view('invoices.create', compact('clients', 'statuses', 'invoiceSettings'));
}
public function store(Request $request): RedirectResponse
@@ -43,13 +45,15 @@ class InvoiceController extends Controller
'issue_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:issue_date',
'notes' => 'nullable|string',
'bank_name' => 'nullable|string|max:100',
'bank_name' => 'nullable|string|max:100',
'bank_account' => 'nullable|string|max:50',
'bank_holder' => 'nullable|string|max:100',
'items' => 'required|array|min:1',
'bank_holder' => 'nullable|string|max:100',
'branch_name' => 'nullable|string|max:100',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.is_taxable' => 'nullable|boolean',
]);
$items = $validated['items'];
@@ -65,15 +69,17 @@ class InvoiceController extends Controller
public function show(Invoice $invoice): View
{
$invoice->load('client', 'items');
return view('invoices.show', compact('invoice'));
$invoiceSettings = InvoiceSettings::instance();
return view('invoices.show', compact('invoice', 'invoiceSettings'));
}
public function edit(Invoice $invoice): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
$invoiceSettings = InvoiceSettings::instance();
$invoice->load('client', 'items');
return view('invoices.edit', compact('invoice', 'clients', 'statuses'));
return view('invoices.edit', compact('invoice', 'clients', 'statuses', 'invoiceSettings'));
}
public function update(Request $request, Invoice $invoice): RedirectResponse
@@ -87,14 +93,16 @@ class InvoiceController extends Controller
'issue_date' => 'required|date',
'due_date' => 'required|date',
'paid_date' => 'nullable|date',
'notes' => 'nullable|string',
'bank_name' => 'nullable|string|max:100',
'notes' => 'nullable|string',
'bank_name' => 'nullable|string|max:100',
'bank_account' => 'nullable|string|max:50',
'bank_holder' => 'nullable|string|max:100',
'items' => 'required|array|min:1',
'bank_holder' => 'nullable|string|max:100',
'branch_name' => 'nullable|string|max:100',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
'items.*.is_taxable' => 'nullable|boolean',
]);
$items = $validated['items'];

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\InvoiceSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class InvoiceSettingsController extends Controller
{
public function edit(): View
{
$settings = InvoiceSettings::instance();
return view('invoices.settings', compact('settings'));
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validate([
'invoice_prefix' => 'required|string|max:20',
'invoice_next_number' => 'required|integer|min:1',
'header_name' => 'nullable|string|max:100',
'header_title' => 'nullable|string|max:150',
'header_logo' => 'nullable|image|max:2048',
'default_tax_rate' => 'nullable|numeric|min:0|max:100',
'default_currency' => 'nullable|string|max:10',
'default_service_description'=> 'nullable|string|max:255',
'default_bank_name' => 'nullable|string|max:100',
'default_branch_name' => 'nullable|string|max:100',
'default_bank_account' => 'nullable|string|max:50',
'default_bank_holder' => 'nullable|string|max:100',
'default_notes' => 'nullable|string|max:1000',
]);
$settings = InvoiceSettings::instance();
if ($request->hasFile('header_logo')) {
// Delete old logo if exists
if ($settings->header_logo) {
Storage::disk('public')->delete($settings->header_logo);
}
$path = $request->file('header_logo')->store('invoice-logos', 'public');
$validated['header_logo'] = $path;
}
$settings->update($validated);
return redirect()->route('invoice-settings.edit')
->with('success', '🧾 Invoice settings saved!');
}
}

View File

@@ -41,7 +41,7 @@ class MediaFileController extends Controller
return view('files.index', compact('files', 'folders', 'folder', 'clients'));
}
public function store(Request $request): RedirectResponse
public function store(Request $request): RedirectResponse|\Illuminate\Http\JsonResponse
{
$request->validate([
'file' => 'required|file|max:51200', // 50 MB
@@ -55,6 +55,10 @@ class MediaFileController extends Controller
$request->input('client_id')
);
if ($request->expectsJson() || $request->ajax()) {
return response()->json(['success' => true, 'message' => 'File uploaded successfully!']);
}
return back()->with('success', '📁 File uploaded successfully!');
}

View File

@@ -23,26 +23,26 @@ class NotificationSettingController extends Controller
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:100',
'channel' => 'required|in:email,telegram,whatsapp',
'triggers' => 'nullable|array',
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
'notify_before_hours' => 'nullable|integer|min:1|max:720',
'name' => 'required|string|max:100',
'channel' => 'required|in:email,telegram,whatsapp',
'triggers' => 'nullable|array',
'triggers.*' => 'in:upcoming_event,invoice_due,content_due',
'notify_before_hours' => 'nullable|integer|min:1|max:720',
// Email
'config.to_email' => 'required_if:channel,email|nullable|email',
'config.to_email' => 'required_if:channel,email|nullable|email',
// Telegram
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
'config.chat_id' => 'required_if:channel,telegram|nullable|string',
'config.bot_token' => 'required_if:channel,telegram|nullable|string',
'config.chat_id' => 'nullable|string',
// WhatsApp (WAHA)
'config.waha_url' => 'required_if:channel,whatsapp|nullable|url',
'config.waha_session' => 'required_if:channel,whatsapp|nullable|string',
'config.phone' => 'required_if:channel,whatsapp|nullable|string',
'config.waha_endpoint' => 'required_if:channel,whatsapp|nullable|url',
'config.waha_session' => 'nullable|string',
'config.api_key' => 'nullable|string',
]);
NotificationSetting::create([
'name' => $validated['name'],
'channel' => $validated['channel'],
'config' => $validated['config'] ?? [],
'config' => $request->input('config', []), // pass raw config — not stripped by validation
'triggers' => $validated['triggers'] ?? [],
'notify_before_hours' => $validated['notify_before_hours'] ?? 24,
'is_enabled' => true,
@@ -65,7 +65,7 @@ class NotificationSettingController extends Controller
'name' => $validated['name'],
'triggers' => $validated['triggers'] ?? [],
'notify_before_hours' => $validated['notify_before_hours'] ?? $setting->notify_before_hours,
'config' => $validated['config'] ?? $setting->config,
'config' => $request->input('config', $setting->config), // use raw input so no keys are stripped
]);
return back()->with('success', '🔔 Notification updated!');

View File

@@ -5,8 +5,11 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\RateCard;
use App\Models\RateCardProfile;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class RateCardController extends Controller
@@ -14,7 +17,8 @@ class RateCardController extends Controller
public function index(): View
{
$rateCards = RateCard::orderBy('sort_order')->orderBy('platform')->get();
return view('rate-cards.index', compact('rateCards'));
$profile = RateCardProfile::getSingleton();
return view('rate-cards.index', compact('rateCards', 'profile'));
}
public function create(): View
@@ -25,15 +29,15 @@ class RateCardController extends Controller
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'service_name' => 'required|string|max:255',
'description' => 'nullable|string',
'platform' => 'required|in:instagram,tiktok,youtube,general',
'content_type' => 'nullable|string|max:50',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:10',
'price_unit' => 'required|in:per_content,per_month,per_campaign',
'service_name' => 'required|string|max:255',
'description' => 'nullable|string',
'platform' => 'required|in:instagram,tiktok,youtube,general',
'content_type' => 'nullable|string|max:50',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:10',
'price_unit' => 'required|in:per_content,per_month,per_campaign',
'is_negotiable' => 'boolean',
'sort_order' => 'nullable|integer',
'sort_order' => 'nullable|integer',
]);
$validated['is_negotiable'] = $request->boolean('is_negotiable');
@@ -53,16 +57,16 @@ class RateCardController extends Controller
public function update(Request $request, RateCard $rateCard): RedirectResponse
{
$validated = $request->validate([
'service_name' => 'required|string|max:255',
'description' => 'nullable|string',
'platform' => 'required|in:instagram,tiktok,youtube,general',
'content_type' => 'nullable|string|max:50',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:10',
'price_unit' => 'required|in:per_content,per_month,per_campaign',
'service_name' => 'required|string|max:255',
'description' => 'nullable|string',
'platform' => 'required|in:instagram,tiktok,youtube,general',
'content_type' => 'nullable|string|max:50',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:10',
'price_unit' => 'required|in:per_content,per_month,per_campaign',
'is_negotiable' => 'boolean',
'is_active' => 'boolean',
'sort_order' => 'nullable|integer',
'is_active' => 'boolean',
'sort_order' => 'nullable|integer',
]);
$validated['is_negotiable'] = $request->boolean('is_negotiable');
@@ -80,14 +84,91 @@ class RateCardController extends Controller
return redirect()->route('rate-cards.index')->with('success', 'Entry removed.');
}
// ── Public Page ──────────────────────────────────────────────
public function public(): View
{
$profile = RateCardProfile::getSingleton();
$rateCards = RateCard::where('is_active', true)
->orderBy('sort_order')
->orderBy('platform')
->get()
->groupBy('platform');
return view('rate-cards.public', compact('rateCards'));
return view('rate-cards.public', compact('rateCards', 'profile'));
}
// ── Profile Settings ─────────────────────────────────────────
public function profilePage(): View
{
$profile = RateCardProfile::getSingleton();
return view('rate-cards.profile', compact('profile'));
}
public function profileUpdate(Request $request): RedirectResponse
{
$validated = $request->validate([
'owner_name' => 'required|string|max:100',
'tagline' => 'nullable|string|max:200',
'whatsapp' => 'nullable|string|max:30',
'ig_handle' => 'nullable|string|max:60',
'tiktok_handle' => 'nullable|string|max:60',
'youtube_handle'=> 'nullable|string|max:60',
'email' => 'nullable|email|max:150',
'bg_color' => 'nullable|string|max:20',
'accent_color' => 'nullable|string|max:20',
'text_color' => 'nullable|string|max:20',
'custom_footer' => 'nullable|string|max:500',
'show_prices' => 'boolean',
'logo' => 'nullable|image|max:2048',
]);
$profile = RateCardProfile::getSingleton();
if ($request->hasFile('logo')) {
$path = $request->file('logo')->store('profile', 'private');
$validated['logo_path'] = $path;
}
$validated['show_prices'] = $request->boolean('show_prices');
unset($validated['logo']);
$profile->update($validated);
return back()->with('success', '🎨 Profile updated!');
}
public function serveProfileLogo(): Response
{
$profile = RateCardProfile::getSingleton();
if (! $profile->logo_path || ! \Storage::disk('private')->exists($profile->logo_path)) {
abort(404);
}
$contents = \Storage::disk('private')->get($profile->logo_path);
$mime = \Storage::disk('private')->mimeType($profile->logo_path);
return response($contents, 200)->header('Content-Type', $mime);
}
// ── PDF Download ─────────────────────────────────────────────
public function pdf(): \Illuminate\Http\Response
{
$profile = RateCardProfile::getSingleton();
$rateCards = RateCard::where('is_active', true)
->orderBy('sort_order')
->orderBy('platform')
->get()
->groupBy('platform');
$pdf = Pdf::loadView('rate-cards.pdf', compact('rateCards', 'profile'))
->setPaper('a4', 'portrait');
$filename = 'rate-card-' . now()->format('Y-m-d') . '.pdf';
return $pdf->download($filename);
}
}

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;
}
}

View File

@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Services;
use App\Models\BrandContract;
use App\Models\Client;
use App\Models\ClientCategory;
use App\Models\ContentBrief;
use App\Models\Event;
use App\Models\Invoice;
use App\Models\MediaFile;
use App\Models\SocialStat;
use Illuminate\Support\Collection;
@@ -21,7 +24,12 @@ final class DashboardService
'upcoming_events' => Event::upcoming(24 * 90)->count(), // 3 months
'pending_invoices' => Invoice::where('status', 'sent')->count(),
'total_revenue' => Invoice::where('status', 'paid')->sum('total'),
'content_in_progress' => ContentBrief::whereNotIn('status', ['posted', 'archived'])->count(),
'content_in_progress' => ContentBrief::whereNotIn('status', ['posted', 'archived'])->count(),
'total_categories' => ClientCategory::whereNull('parent_id')->count(),
'total_subcategories' => ClientCategory::whereNotNull('parent_id')->count(),
'total_files' => MediaFile::count(),
'active_contracts' => BrandContract::where('is_active', true)->where('end_date', '>=', today())->count(),
'total_contracts' => BrandContract::count(),
];
}

View File

@@ -6,6 +6,7 @@ namespace App\Services;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\InvoiceSettings;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Response;
@@ -22,7 +23,8 @@ final class InvoiceService
]);
foreach ($items as $item) {
$item['total'] = $item['quantity'] * $item['unit_price'];
$item['total'] = $item['quantity'] * $item['unit_price'];
$item['is_taxable'] = isset($item['is_taxable']) && $item['is_taxable'] ? true : false;
$invoice->items()->create($item);
}
@@ -37,7 +39,8 @@ final class InvoiceService
$invoice->items()->delete();
foreach ($items as $item) {
$item['total'] = $item['quantity'] * $item['unit_price'];
$item['total'] = $item['quantity'] * $item['unit_price'];
$item['is_taxable'] = isset($item['is_taxable']) && $item['is_taxable'] ? true : false;
$invoice->items()->create($item);
}
@@ -49,8 +52,9 @@ final class InvoiceService
public function generatePdf(Invoice $invoice): Response
{
$invoice->load('client', 'items');
$invoiceSettings = InvoiceSettings::instance();
$pdf = Pdf::loadView('invoices.pdf', compact('invoice'));
$pdf = Pdf::loadView('invoices.pdf', compact('invoice', 'invoiceSettings'));
$pdf->setPaper('A4', 'portrait');
return $pdf->download("invoice-{$invoice->invoice_number}.pdf");

View File

@@ -30,26 +30,33 @@ class WahaNotifier
private function send(NotificationSetting $setting, string $text): void
{
$config = $setting->config ?? [];
$url = rtrim($config['waha_url'] ?? '', '/');
$session = $config['waha_session'] ?? 'default';
$phone = $config['phone'] ?? null;
$config = $setting->config ?? [];
$endpoint = $config['waha_endpoint'] ?? null;
$session = $config['waha_session'] ?? 'default';
$chatId = $config['chat_id'] ?? null;
$apiKey = $config['api_key'] ?? null;
if (! $url || ! $phone) {
throw new \RuntimeException('WAHA waha_url and phone are required.');
if (! $endpoint || ! $chatId) {
throw new \RuntimeException('WAHA waha_endpoint and chat_id are required.');
}
// Normalise phone: strip non-digits, ensure no leading +
$chatId = preg_replace('/[^0-9]/', '', $phone) . '@c.us';
$http = Http::timeout(15)->acceptJson()->contentType('application/json');
$response = Http::timeout(15)->post("{$url}/api/sendText", [
'session' => $session,
if ($apiKey) {
$http = $http->withHeaders(['X-Api-Key' => $apiKey]);
}
$response = $http->post($endpoint, [
'chatId' => $chatId,
'text' => $text,
'session' => $session,
]);
if (! $response->successful()) {
throw new \RuntimeException('WAHA API error: ' . $response->body());
$keyHint = $apiKey ? '(api_key set, length=' . strlen($apiKey) . ')' : '(no api_key)';
throw new \RuntimeException(
"WAHA API error {$response->status()} {$keyHint} → " . $response->body()
);
}
}
}