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