initial commit

This commit is contained in:
I-Fun
2026-04-01 08:04:56 +07:00
commit e09c8a5fa9
111 changed files with 18022 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "KOL Manager (Laravel)",
"runtimeExecutable": "C:/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe",
"runtimeArgs": ["artisan", "serve", "--port=8765"],
"port": 8765
}
]
}

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\" COMPOSER_PHAR=\"/c/laragon/bin/composer/composer.phar\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_cff6313b647103e9__ \"$PHP_BIN\" \"$COMPOSER_PHAR\" require barryvdh/laravel-dompdf spatie/laravel-permission)",
"Bash(NODE_BIN=\"/c/laragon/bin/nodejs/node-v22\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_568171a788d80758__ \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" install -D tailwindcss@latest daisyui autoprefixer alpinejs @alpinejs/sort)",
"Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\")",
"Bash(__NEW_LINE_24ed59dbe8b64f63__ \"$PHP_BIN\" artisan migrate --force)",
"Bash(NODE_BIN=\"/c/laragon/bin/nodejs/node-v22\")",
"Bash(__NEW_LINE_cca095d4b4928730__ \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" run build)",
"Bash(__NEW_LINE_0726fb8d660061fe__ \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" run build)",
"Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_c826a3b5159ac081__ \"$PHP_BIN\" artisan route:list --columns=method,uri,name)",
"Bash(PHP_BIN=\"/c/laragon/bin/php/php-8.3.16-Win32-vs16-x64/php.exe\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" __NEW_LINE_7c463fa4c32b98a2__ \"$PHP_BIN\" artisan route:list)",
"Bash(NODE_BIN=\"/c/laragon/bin/nodejs/node-v22\" cd \"E:/_PERSONAL-PROJECT_/PHP/KOL-Manager\" \"$NODE_BIN/node.exe\" \"$NODE_BIN/node_modules/npm/bin/npm-cli.js\" run build)",
"Bash(/c/laragon/bin/nodejs/node-v22/node node_modules/npm/bin/npm-cli.js run build)",
"Bash(/c/laragon/bin/nodejs/node-v22/node /c/laragon/bin/nodejs/node-v22/node_modules/npm/bin/npm-cli.js run build)"
]
}
}

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

58
README.md Normal file
View File

@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ClientStatus: string
{
case Active = 'active';
case Inactive = 'inactive';
case Prospect = 'prospect';
public function label(): string
{
return match($this) {
self::Active => 'Active',
self::Inactive => 'Inactive',
self::Prospect => 'Prospect',
};
}
public function badgeClass(): string
{
return match($this) {
self::Active => 'badge-success',
self::Inactive => 'badge-error',
self::Prospect => 'badge-warning',
};
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ContentStatus: string
{
case Drafting = 'drafting';
case Filming = 'filming';
case Editing = 'editing';
case Review = 'review';
case Posted = 'posted';
case Archived = 'archived';
public function label(): string
{
return match($this) {
self::Drafting => 'Drafting',
self::Filming => 'Filming',
self::Editing => 'Editing',
self::Review => 'Review',
self::Posted => 'Posted',
self::Archived => 'Archived',
};
}
public function icon(): string
{
return match($this) {
self::Drafting => '✏️',
self::Filming => '🎬',
self::Editing => '✂️',
self::Review => '👁️',
self::Posted => '✅',
self::Archived => '📦',
};
}
public function colorClass(): string
{
return match($this) {
self::Drafting => 'bg-base-200',
self::Filming => 'bg-warning/20',
self::Editing => 'bg-info/20',
self::Review => 'bg-secondary/20',
self::Posted => 'bg-success/20',
self::Archived => 'bg-neutral/20',
};
}
public function badgeClass(): string
{
return match($this) {
self::Drafting => 'badge-ghost',
self::Filming => 'badge-warning',
self::Editing => 'badge-info',
self::Review => 'badge-secondary',
self::Posted => 'badge-success',
self::Archived => 'badge-neutral',
};
}
}

33
app/Enums/EventStatus.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum EventStatus: string
{
case Pending = 'pending';
case InProgress = 'in_progress';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Pending',
self::InProgress => 'In Progress',
self::Completed => 'Completed',
self::Cancelled => 'Cancelled',
};
}
public function badgeClass(): string
{
return match($this) {
self::Pending => 'badge-warning',
self::InProgress => 'badge-info',
self::Completed => 'badge-success',
self::Cancelled => 'badge-error',
};
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum InvoiceStatus: string
{
case Draft = 'draft';
case Sent = 'sent';
case Paid = 'paid';
case Overdue = 'overdue';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Draft => 'Draft',
self::Sent => 'Sent',
self::Paid => 'Paid',
self::Overdue => 'Overdue',
self::Cancelled => 'Cancelled',
};
}
public function badgeClass(): string
{
return match($this) {
self::Draft => 'badge-ghost',
self::Sent => 'badge-info',
self::Paid => 'badge-success',
self::Overdue => 'badge-error',
self::Cancelled => 'badge-neutral',
};
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\ClientStatus;
use App\Models\Client;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ClientController extends Controller
{
public function index(): View
{
$clients = Client::withCount(['invoices', 'events', 'contentBriefs'])
->latest()
->paginate(12);
return view('clients.index', compact('clients'));
}
public function create(): View
{
$statuses = ClientStatus::cases();
return view('clients.create', compact('statuses'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'nullable|email|unique:clients,email',
'phone' => 'nullable|string|max:50',
'company' => 'nullable|string|max:255',
'ig_handle' => 'nullable|string|max:100',
'tiktok_handle' => 'nullable|string|max:100',
'youtube_handle' => 'nullable|string|max:100',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'status' => 'required|in:active,inactive,prospect',
'notes' => 'nullable|string',
]);
Client::create($validated);
return redirect()->route('clients.index')
->with('success', '🐱 Client added successfully!');
}
public function show(Client $client): View
{
$client->load(['events' => fn ($q) => $q->latest()->limit(5)]);
$client->load(['invoices' => fn ($q) => $q->latest()->limit(5)]);
$client->load(['contentBriefs' => fn ($q) => $q->latest()->limit(5)]);
$client->load(['mediaFiles' => fn ($q) => $q->latest()->limit(10)]);
return view('clients.show', compact('client'));
}
public function edit(Client $client): View
{
$statuses = ClientStatus::cases();
return view('clients.edit', compact('client', 'statuses'));
}
public function update(Request $request, Client $client): RedirectResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'nullable|email|unique:clients,email,' . $client->id,
'phone' => 'nullable|string|max:50',
'company' => 'nullable|string|max:255',
'ig_handle' => 'nullable|string|max:100',
'tiktok_handle' => 'nullable|string|max:100',
'youtube_handle' => 'nullable|string|max:100',
'address_line1' => 'nullable|string|max:255',
'address_line2' => 'nullable|string|max:255',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:100',
'postal_code' => 'nullable|string|max:20',
'country' => 'nullable|string|max:100',
'status' => 'required|in:active,inactive,prospect',
'notes' => 'nullable|string',
]);
$client->update($validated);
return redirect()->route('clients.show', $client)
->with('success', '🐱 Client updated successfully!');
}
public function destroy(Client $client): RedirectResponse
{
$client->delete();
return redirect()->route('clients.index')
->with('success', '🐱 Client removed.');
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\ContentStatus;
use App\Models\Client;
use App\Models\ContentBrief;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ContentBriefController extends Controller
{
public function index(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$columns = collect(ContentStatus::cases())->map(fn (ContentStatus $status) => [
'status' => $status,
'items' => ContentBrief::with('client')
->where('status', $status->value)
->orderBy('sort_order')
->orderBy('target_date')
->get(),
]);
return view('content.index', compact('columns', 'clients'));
}
public function create(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
return view('content.create', compact('clients', 'statuses'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'platform' => 'required|in:instagram,tiktok,youtube',
'content_type' => 'required|string|max:50',
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
'hook' => 'nullable|string',
'body' => 'nullable|string',
'cta' => 'nullable|string',
'script' => 'nullable|string',
'caption' => 'nullable|string',
'hashtags' => 'nullable|string',
'target_date' => 'nullable|date',
'notes' => 'nullable|string',
]);
ContentBrief::create($validated);
return redirect()->route('content.index')
->with('success', '🎬 Content brief created!');
}
public function show(ContentBrief $contentBrief): View
{
$contentBrief->load('client');
return view('content.show', ['brief' => $contentBrief]);
}
public function edit(ContentBrief $contentBrief): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = ContentStatus::cases();
$contentBrief->load('client');
return view('content.edit', ['brief' => $contentBrief, 'clients' => $clients, 'statuses' => $statuses]);
}
public function update(Request $request, ContentBrief $contentBrief): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'client_id' => 'nullable|exists:clients,id',
'platform' => 'required|in:instagram,tiktok,youtube',
'content_type' => 'required|string|max:50',
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
'hook' => 'nullable|string',
'body' => 'nullable|string',
'cta' => 'nullable|string',
'script' => 'nullable|string',
'caption' => 'nullable|string',
'hashtags' => 'nullable|string',
'target_date' => 'nullable|date',
'posted_date' => 'nullable|date',
'posted_url' => 'nullable|url',
'notes' => 'nullable|string',
]);
$contentBrief->update($validated);
return redirect()->route('content.show', $contentBrief)
->with('success', '🎬 Content brief updated!');
}
public function destroy(ContentBrief $contentBrief): RedirectResponse
{
$contentBrief->delete();
return redirect()->route('content.index')->with('success', 'Content brief removed.');
}
public function updateStatus(Request $request, ContentBrief $contentBrief): JsonResponse
{
$validated = $request->validate([
'status' => 'required|in:drafting,filming,editing,review,posted,archived',
'sort_order' => 'nullable|integer',
]);
$contentBrief->update($validated);
return response()->json(['ok' => true]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\DashboardService;
use Illuminate\View\View;
class DashboardController extends Controller
{
public function __construct(
private readonly DashboardService $dashboardService
) {}
public function index(): View
{
return view('dashboard', [
'stats' => $this->dashboardService->getStats(),
'todayActivities' => $this->dashboardService->getTodayActivities(),
'latestInvoices' => $this->dashboardService->getLatestInvoices(),
'socialStats' => $this->dashboardService->getSocialStats(),
]);
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\EventStatus;
use App\Models\Client;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EventController extends Controller
{
public function index(): View
{
$events = Event::with('client')->orderBy('start_time')->get();
$clients = Client::where('status', 'active')->orderBy('name')->get();
// Format for FullCalendar
$calendarEvents = $events->map(fn (Event $e) => [
'id' => $e->id,
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time?->toIso8601String(),
'backgroundColor' => $e->color,
'borderColor' => $e->color,
'extendedProps' => [
'status' => $e->status->value,
'client' => $e->client?->name,
'type' => $e->event_type,
'priority' => $e->is_priority,
],
]);
return view('schedule.index', compact('events', 'clients', 'calendarEvents'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'client_id' => 'nullable|exists:clients,id',
'event_type' => 'required|in:task,meeting,deadline,posting',
'start_time' => 'required|date',
'end_time' => 'nullable|date|after:start_time',
'location' => 'nullable|string|max:255',
'is_priority' => 'boolean',
'is_all_day' => 'boolean',
'color' => 'nullable|string|max:20',
'notes' => 'nullable|string',
]);
$validated['is_priority'] = $request->boolean('is_priority');
$validated['is_all_day'] = $request->boolean('is_all_day');
Event::create($validated);
return redirect()->route('schedule.index')
->with('success', '🐾 Event scheduled!');
}
public function update(Request $request, Event $event): RedirectResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'client_id' => 'nullable|exists:clients,id',
'event_type' => 'required|in:task,meeting,deadline,posting',
'start_time' => 'required|date',
'end_time' => 'nullable|date',
'location' => 'nullable|string|max:255',
'is_priority' => 'boolean',
'status' => 'required|in:pending,in_progress,completed,cancelled',
'color' => 'nullable|string|max:20',
'notes' => 'nullable|string',
]);
$validated['is_priority'] = $request->boolean('is_priority');
$event->update($validated);
return redirect()->route('schedule.index')
->with('success', '🐾 Event updated!');
}
public function updateDates(Request $request, Event $event): JsonResponse
{
$validated = $request->validate([
'start_time' => 'required|date',
'end_time' => 'nullable|date',
]);
$event->update($validated);
return response()->json(['ok' => true]);
}
public function destroy(Event $event): RedirectResponse
{
$event->delete();
return redirect()->route('schedule.index')
->with('success', '🐾 Event removed.');
}
public function apiList(): JsonResponse
{
$events = Event::with('client')->get()->map(fn (Event $e) => [
'id' => $e->id,
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
'start' => $e->start_time->toIso8601String(),
'end' => $e->end_time?->toIso8601String(),
'backgroundColor' => $e->color,
'borderColor' => $e->color,
'allDay' => $e->is_all_day,
'extendedProps' => [
'status' => $e->status->value,
'client' => $e->client?->name,
'type' => $e->event_type,
'priority' => $e->is_priority,
],
]);
return response()->json($events);
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Enums\InvoiceStatus;
use App\Models\Client;
use App\Models\Invoice;
use App\Services\InvoiceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class InvoiceController extends Controller
{
public function __construct(
private readonly InvoiceService $invoiceService
) {}
public function index(): View
{
$invoices = Invoice::with('client')->latest()->paginate(15);
return view('invoices.index', compact('invoices'));
}
public function create(): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
return view('invoices.create', compact('clients', 'statuses'));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'client_id' => 'nullable|exists:clients,id',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'tax_rate' => 'required|numeric|min:0|max:100',
'currency' => 'required|string|max:10',
'issue_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:issue_date',
'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',
'items.*.description' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
$items = $validated['items'];
unset($validated['items']);
$validated['status'] = InvoiceStatus::Draft->value;
$invoice = $this->invoiceService->create($validated, $items);
return redirect()->route('invoices.show', $invoice)
->with('success', '🧾 Invoice created!');
}
public function show(Invoice $invoice): View
{
$invoice->load('client', 'items');
return view('invoices.show', compact('invoice'));
}
public function edit(Invoice $invoice): View
{
$clients = Client::where('status', 'active')->orderBy('name')->get();
$statuses = InvoiceStatus::cases();
$invoice->load('client', 'items');
return view('invoices.edit', compact('invoice', 'clients', 'statuses'));
}
public function update(Request $request, Invoice $invoice): RedirectResponse
{
$validated = $request->validate([
'client_id' => 'nullable|exists:clients,id',
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'tax_rate' => 'required|numeric|min:0|max:100',
'status' => 'required|in:draft,sent,paid,overdue,cancelled',
'issue_date' => 'required|date',
'due_date' => 'required|date',
'paid_date' => 'nullable|date',
'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',
'items.*.description' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
$items = $validated['items'];
unset($validated['items']);
$this->invoiceService->update($invoice, $validated, $items);
return redirect()->route('invoices.show', $invoice)
->with('success', '🧾 Invoice updated!');
}
public function destroy(Invoice $invoice): RedirectResponse
{
$invoice->delete();
return redirect()->route('invoices.index')->with('success', 'Invoice deleted.');
}
public function pdf(Invoice $invoice): Response
{
return $this->invoiceService->generatePdf($invoice);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\MediaFile;
use App\Services\FileService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class MediaFileController extends Controller
{
public function __construct(
private readonly FileService $fileService
) {}
public function index(Request $request): View
{
$folder = $request->get('folder', 'all');
$client = $request->get('client_id');
$clients = Client::orderBy('name')->get();
$query = MediaFile::with('client')->latest();
if ($folder !== 'all') {
$query->where('folder', $folder);
}
if ($client) {
$query->where('client_id', $client);
}
$files = $query->paginate(20)->withQueryString();
$folders = MediaFile::select('folder')->distinct()->pluck('folder');
return view('files.index', compact('files', 'folders', 'folder', 'clients'));
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'file' => 'required|file|max:51200', // 50 MB
'folder' => 'required|string|max:100',
'client_id' => 'nullable|exists:clients,id',
]);
$this->fileService->uploadAsset(
$request->file('file'),
$request->input('folder'),
$request->input('client_id')
);
return back()->with('success', '📁 File uploaded successfully!');
}
public function serve(MediaFile $mediaFile): StreamedResponse
{
abort_unless(Storage::disk($mediaFile->disk)->exists($mediaFile->path), 404);
return Storage::disk($mediaFile->disk)->response(
$mediaFile->path,
$mediaFile->original_name,
['Content-Type' => $mediaFile->mime_type]
);
}
public function destroy(MediaFile $mediaFile): RedirectResponse
{
$this->fileService->delete($mediaFile);
return back()->with('success', 'File deleted.');
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\RateCard;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class RateCardController extends Controller
{
public function index(): View
{
$rateCards = RateCard::orderBy('sort_order')->orderBy('platform')->get();
return view('rate-cards.index', compact('rateCards'));
}
public function create(): View
{
return view('rate-cards.create');
}
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',
'is_negotiable' => 'boolean',
'sort_order' => 'nullable|integer',
]);
$validated['is_negotiable'] = $request->boolean('is_negotiable');
$validated['is_active'] = true;
RateCard::create($validated);
return redirect()->route('rate-cards.index')
->with('success', '💰 Rate card entry added!');
}
public function edit(RateCard $rateCard): View
{
return view('rate-cards.edit', compact('rateCard'));
}
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',
'is_negotiable' => 'boolean',
'is_active' => 'boolean',
'sort_order' => 'nullable|integer',
]);
$validated['is_negotiable'] = $request->boolean('is_negotiable');
$validated['is_active'] = $request->boolean('is_active');
$rateCard->update($validated);
return redirect()->route('rate-cards.index')
->with('success', '💰 Rate card updated!');
}
public function destroy(RateCard $rateCard): RedirectResponse
{
$rateCard->delete();
return redirect()->route('rate-cards.index')->with('success', 'Entry removed.');
}
public function public(): View
{
$rateCards = RateCard::where('is_active', true)
->orderBy('sort_order')
->orderBy('platform')
->get()
->groupBy('platform');
return view('rate-cards.public', compact('rateCards'));
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ContentBrief;
use App\Models\SocialStat;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SocialHubController extends Controller
{
public function index(): View
{
// Get latest stats per platform
$stats = collect(['instagram', 'tiktok', 'youtube'])->map(fn (string $platform) => [
'platform' => $platform,
'latest' => SocialStat::where('platform', $platform)
->orderByDesc('recorded_date')
->first(),
'history' => SocialStat::where('platform', $platform)
->orderByDesc('recorded_date')
->limit(7)
->get(),
]);
// Posting frequency - count by platform per month
$postingFrequency = ContentBrief::where('status', 'posted')
->selectRaw("platform, strftime('%Y-%m', posted_date) as month, count(*) as count")
->whereNotNull('posted_date')
->groupBy('platform', 'month')
->orderBy('month')
->limit(30)
->get();
return view('social-hub.index', compact('stats', 'postingFrequency'));
}
public function storeStats(Request $request): RedirectResponse
{
$validated = $request->validate([
'platform' => 'required|in:instagram,tiktok,youtube',
'handle' => 'nullable|string|max:100',
'followers' => 'required|integer|min:0',
'following' => 'nullable|integer|min:0',
'posts_count' => 'nullable|integer|min:0',
'engagement_rate' => 'nullable|numeric|min:0|max:100',
'avg_views' => 'nullable|integer|min:0',
'avg_likes' => 'nullable|integer|min:0',
'avg_comments' => 'nullable|integer|min:0',
]);
$validated['recorded_date'] = today()->toDateString();
SocialStat::create($validated);
return back()->with('success', '📊 Stats updated!');
}
}

67
app/Models/Client.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\ClientStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Client extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name', 'email', 'phone', 'company',
'ig_handle', 'tiktok_handle', 'youtube_handle',
'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country',
'status', 'notes', 'avatar',
];
protected $casts = [
'status' => ClientStatus::class,
];
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function contentBriefs(): HasMany
{
return $this->hasMany(ContentBrief::class);
}
public function mediaFiles(): HasMany
{
return $this->hasMany(MediaFile::class);
}
public function getFullAddressAttribute(): string
{
return collect([
$this->address_line1,
$this->address_line2,
$this->city,
$this->state,
$this->postal_code,
$this->country,
])->filter()->implode(', ');
}
public function getInitialsAttribute(): string
{
$words = explode(' ', $this->name);
return strtoupper(
collect($words)->take(2)->map(fn ($w) => $w[0] ?? '')->implode('')
);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\ContentStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ContentBrief extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'client_id', 'title', 'platform', 'content_type', 'status',
'hook', 'body', 'cta', 'script', 'caption', 'hashtags',
'target_date', 'posted_date', 'posted_url', 'sort_order', 'notes',
];
protected $casts = [
'status' => ContentStatus::class,
'target_date' => 'date',
'posted_date' => 'date',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function getPlatformIconAttribute(): string
{
return match($this->platform) {
'instagram' => '📸',
'tiktok' => '🎵',
'youtube' => '▶️',
default => '📱',
};
}
}

48
app/Models/Event.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\EventStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Event extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'client_id', 'title', 'description', 'event_type',
'start_time', 'end_time', 'location', 'is_priority',
'is_all_day', 'status', 'color', 'notes',
];
protected $casts = [
'start_time' => 'datetime',
'end_time' => 'datetime',
'is_priority' => 'boolean',
'is_all_day' => 'boolean',
'status' => EventStatus::class,
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function scopeUpcoming($query, int $hours = 24)
{
return $query->where('start_time', '>=', now())
->where('start_time', '<=', now()->addHours($hours))
->where('status', '!=', EventStatus::Completed->value)
->where('status', '!=', EventStatus::Cancelled->value);
}
public function scopeToday($query)
{
return $query->whereDate('start_time', today());
}
}

65
app/Models/Invoice.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\InvoiceStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'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',
];
protected $casts = [
'status' => InvoiceStatus::class,
'issue_date' => 'date',
'due_date' => 'date',
'paid_date' => 'date',
'subtotal' => 'decimal:2',
'tax_rate' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total' => 'decimal:2',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
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;
$this->save();
}
public function getFormattedTotalAttribute(): string
{
return 'Rp ' . number_format((float) $this->total, 0, ',', '.');
}
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);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoiceItem extends Model
{
protected $fillable = ['invoice_id', 'description', 'quantity', 'unit_price', 'total'];
protected $casts = [
'unit_price' => 'decimal:2',
'total' => 'decimal:2',
];
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
}

51
app/Models/MediaFile.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
class MediaFile extends Model
{
use SoftDeletes;
protected $fillable = [
'client_id', 'name', 'original_name', 'path', 'disk', 'mime_type', 'size', 'folder', 'description',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function getUrlAttribute(): string
{
return route('files.serve', $this->id);
}
public function getHumanSizeAttribute(): string
{
$bytes = $this->size;
if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB';
if ($bytes >= 1048576) return round($bytes / 1048576, 2) . ' MB';
if ($bytes >= 1024) return round($bytes / 1024, 2) . ' KB';
return $bytes . ' B';
}
public function getIsImageAttribute(): bool
{
return str_starts_with($this->mime_type, 'image/');
}
public function getIconAttribute(): string
{
if ($this->is_image) return '🖼️';
if (str_starts_with($this->mime_type, 'video/')) return '🎬';
if (str_contains($this->mime_type, 'pdf')) return '📄';
return '📁';
}
}

36
app/Models/RateCard.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class RateCard extends Model
{
protected $fillable = [
'service_name', 'description', 'platform', 'content_type',
'price', 'currency', 'price_unit', 'is_negotiable', 'is_active', 'sort_order',
];
protected $casts = [
'price' => 'decimal:2',
'is_negotiable' => 'boolean',
'is_active' => 'boolean',
];
public function getFormattedPriceAttribute(): string
{
return 'Rp ' . number_format((float) $this->price, 0, ',', '.');
}
public function getPlatformIconAttribute(): string
{
return match($this->platform) {
'instagram' => '📸',
'tiktok' => '🎵',
'youtube' => '▶️',
default => '⭐',
};
}
}

40
app/Models/SocialStat.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SocialStat extends Model
{
protected $fillable = [
'platform', 'handle', 'followers', 'following', 'posts_count',
'engagement_rate', 'avg_views', 'avg_likes', 'avg_comments',
'recorded_date', 'raw_data',
];
protected $casts = [
'recorded_date' => 'date',
'raw_data' => 'array',
'engagement_rate' => 'decimal:2',
];
public function getFormattedFollowersAttribute(): string
{
$n = $this->followers;
if ($n >= 1_000_000) return round($n / 1_000_000, 1) . 'M';
if ($n >= 1_000) return round($n / 1_000, 1) . 'K';
return (string) $n;
}
public function getPlatformIconAttribute(): string
{
return match($this->platform) {
'instagram' => '📸',
'tiktok' => '🎵',
'youtube' => '▶️',
default => '📱',
};
}
}

32
app/Models/User.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void
{
// Inject alert badge count into all views
View::composer('*', function (\Illuminate\View\View $view): void {
try {
$alertCount = Event::upcoming(24)->count();
} catch (\Throwable) {
$alertCount = 0;
}
$view->with('alertCount', $alertCount);
});
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Client;
use App\Models\ContentBrief;
use App\Models\Event;
use App\Models\Invoice;
use App\Models\SocialStat;
use Illuminate\Support\Collection;
final class DashboardService
{
public function getStats(): array
{
return [
'total_clients' => Client::count(),
'active_clients' => Client::where('status', 'active')->count(),
'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(),
];
}
/**
* Combined today's activities: events (today or ongoing), invoices due, content targets.
*/
public function getTodayActivities(): Collection
{
$activities = collect();
// Events starting today OR currently ongoing (started before today, ends today or later)
$events = Event::with('client')
->where(function ($q) {
$q->whereDate('start_time', today())
->orWhere(function ($q2) {
$q2->whereDate('start_time', '<', today())
->where(function ($q3) {
$q3->whereDate('end_time', '>=', today())
->orWhereNull('end_time');
})
->whereNotIn('status', ['completed', 'cancelled']);
});
})
->orderBy('start_time')
->limit(15)
->get();
foreach ($events as $e) {
$timeLabel = $e->is_all_day
? 'All day'
: ($e->start_time->format('H:i') . ($e->end_time ? ' – ' . $e->end_time->format('H:i') : ''));
$activities->push([
'type' => 'event',
'icon' => '📅',
'title' => $e->title,
'subtitle' => $timeLabel . ($e->client ? ' · ' . $e->client->name : ''),
'label' => $e->event_type,
'badge' => $e->status->label(),
'badge_class' => $e->status->badgeClass(),
'color' => $e->color ?? '#FFB7C5',
'priority' => $e->is_priority,
'sort_key' => $e->start_time->timestamp,
]);
}
// Invoices due today (not yet paid or cancelled)
$invoices = Invoice::with('client')
->whereDate('due_date', today())
->whereNotIn('status', ['paid', 'cancelled'])
->get();
foreach ($invoices as $inv) {
$activities->push([
'type' => 'invoice',
'icon' => '🧾',
'title' => $inv->title ?? $inv->invoice_number,
'subtitle' => ($inv->client?->name ?? 'No client') . ' · ' . ($inv->formatted_total ?? ''),
'label' => 'Invoice Due',
'badge' => $inv->status->label(),
'badge_class' => $inv->status->badgeClass(),
'color' => '#FDFD96',
'priority' => false,
'sort_key' => today()->setTime(23, 59, 0)->timestamp,
]);
}
// Content briefs with target date today (not posted/archived)
$content = ContentBrief::with('client')
->whereDate('target_date', today())
->whereNotIn('status', ['posted', 'archived'])
->get();
foreach ($content as $c) {
$activities->push([
'type' => 'content',
'icon' => $c->platform_icon,
'title' => $c->title,
'subtitle' => ucfirst($c->platform) . ($c->client ? ' · ' . $c->client->name : ''),
'label' => 'Content Target',
'badge' => $c->status->label(),
'badge_class' => 'badge-ghost',
'color' => '#E8D5F5',
'priority' => false,
'sort_key' => today()->setTime(12, 0, 0)->timestamp,
]);
}
return $activities->sortBy('sort_key')->values();
}
public function getAlertCount(): int
{
return Event::upcoming(24)->count();
}
public function getLatestInvoices(): Collection
{
return Invoice::with('client')
->latest()
->limit(5)
->get();
}
public function getSocialStats(): Collection
{
return SocialStat::whereIn('id', function ($query): void {
$query->selectRaw('MAX(id)')
->from('social_stats')
->groupBy('platform');
})->get();
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\MediaFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class FileService
{
public function uploadAsset(UploadedFile $file, string $folder, ?int $clientId = null): MediaFile
{
// Sanitise folder name — allow alphanumeric, dash, underscore only
$folder = preg_replace('/[^a-zA-Z0-9\-_]/', '-', trim($folder)) ?: 'general';
$name = Str::uuid() . '.' . $file->getClientOriginalExtension();
$directory = "talent-assets/{$folder}";
// Ensure the directory exists on the local disk
Storage::disk('local')->makeDirectory($directory);
$path = $file->storeAs($directory, $name, 'local');
return MediaFile::create([
'client_id' => $clientId,
'name' => pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME),
'original_name' => $file->getClientOriginalName(),
'path' => $path,
'disk' => 'local',
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'folder' => $folder,
]);
}
public function delete(MediaFile $file): void
{
Storage::disk($file->disk)->delete($file->path);
$file->delete();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Response;
final class InvoiceService
{
public function create(array $data, array $items): Invoice
{
$invoice = Invoice::create([
...$data,
'invoice_number' => Invoice::generateNumber(),
'subtotal' => 0,
'tax_amount' => 0,
'total' => 0,
]);
foreach ($items as $item) {
$item['total'] = $item['quantity'] * $item['unit_price'];
$invoice->items()->create($item);
}
$invoice->recalculate();
return $invoice;
}
public function update(Invoice $invoice, array $data, array $items): Invoice
{
$invoice->update($data);
$invoice->items()->delete();
foreach ($items as $item) {
$item['total'] = $item['quantity'] * $item['unit_price'];
$invoice->items()->create($item);
}
$invoice->recalculate();
return $invoice->fresh();
}
public function generatePdf(Invoice $invoice): Response
{
$invoice->load('client', 'items');
$pdf = Pdf::loadView('invoices.pdf', compact('invoice'));
$pdf->setPaper('A4', 'portrait');
return $pdf->download("invoice-{$invoice->invoice_number}.pdf");
}
}

18
artisan Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

18
bootstrap/app.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

7
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];

87
composer.json Normal file
View File

@@ -0,0 +1,87 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^13.0",
"laravel/tinker": "^3.0",
"spatie/laravel-permission": "^7.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5.12"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8822
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
config/auth.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

130
config/cache.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
config/database.php Normal file
View File

@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

80
config/filesystems.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
config/queue.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
config/session.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
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::create('clients', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->nullable()->unique();
$table->string('phone')->nullable();
$table->string('company')->nullable();
$table->string('ig_handle')->nullable();
$table->string('tiktok_handle')->nullable();
$table->string('youtube_handle')->nullable();
$table->string('address_line1')->nullable();
$table->string('address_line2')->nullable();
$table->string('city')->nullable();
$table->string('state')->nullable();
$table->string('postal_code')->nullable();
$table->string('country')->default('Indonesia');
$table->string('status')->default('active'); // active, inactive, prospect
$table->text('notes')->nullable();
$table->string('avatar')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('clients');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
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::create('events', function (Blueprint $table): void {
$table->id();
$table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('event_type')->default('task'); // task, meeting, deadline, posting
$table->dateTime('start_time');
$table->dateTime('end_time')->nullable();
$table->string('location')->nullable();
$table->boolean('is_priority')->default(false);
$table->boolean('is_all_day')->default(false);
$table->string('status')->default('pending'); // pending, in_progress, completed, cancelled
$table->string('color')->default('#FFB7C5');
$table->text('notes')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('events');
}
};

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
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::create('invoices', function (Blueprint $table): void {
$table->id();
$table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$table->string('invoice_number')->unique();
$table->string('title');
$table->text('description')->nullable();
$table->decimal('subtotal', 15, 2)->default(0);
$table->decimal('tax_rate', 5, 2)->default(11); // PPN Indonesia 11%
$table->decimal('tax_amount', 15, 2)->default(0);
$table->decimal('total', 15, 2)->default(0);
$table->string('currency')->default('IDR');
$table->string('status')->default('draft'); // draft, sent, paid, overdue, cancelled
$table->date('issue_date');
$table->date('due_date');
$table->date('paid_date')->nullable();
$table->text('notes')->nullable();
$table->string('bank_name')->nullable();
$table->string('bank_account')->nullable();
$table->string('bank_holder')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('invoice_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('invoice_id')->constrained()->cascadeOnDelete();
$table->string('description');
$table->integer('quantity')->default(1);
$table->decimal('unit_price', 15, 2);
$table->decimal('total', 15, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invoice_items');
Schema::dropIfExists('invoices');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
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::create('rate_cards', function (Blueprint $table): void {
$table->id();
$table->string('service_name');
$table->text('description')->nullable();
$table->string('platform')->default('general'); // instagram, tiktok, youtube, general
$table->string('content_type')->nullable(); // feed, reel, story, short, video
$table->decimal('price', 15, 2);
$table->string('currency')->default('IDR');
$table->string('price_unit')->default('per_content'); // per_content, per_month, per_campaign
$table->boolean('is_negotiable')->default(true);
$table->boolean('is_active')->default(true);
$table->integer('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('rate_cards');
}
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
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::create('content_briefs', function (Blueprint $table): void {
$table->id();
$table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$table->string('title');
$table->string('platform'); // instagram, tiktok, youtube
$table->string('content_type'); // reel, story, feed, short, video
$table->string('status')->default('drafting'); // drafting, filming, editing, review, posted, archived
$table->text('hook')->nullable();
$table->text('body')->nullable();
$table->text('cta')->nullable();
$table->text('script')->nullable();
$table->text('caption')->nullable();
$table->string('hashtags')->nullable();
$table->date('target_date')->nullable();
$table->date('posted_date')->nullable();
$table->string('posted_url')->nullable();
$table->integer('sort_order')->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('content_briefs');
}
};

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
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::create('media_files', function (Blueprint $table): void {
$table->id();
$table->foreignId('client_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('original_name');
$table->string('path');
$table->string('disk')->default('local');
$table->string('mime_type');
$table->unsignedBigInteger('size'); // bytes
$table->string('folder')->default('general');
$table->text('description')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('media_files');
}
};

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
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::create('social_stats', function (Blueprint $table): void {
$table->id();
$table->string('platform'); // instagram, tiktok, youtube
$table->string('handle')->nullable();
$table->unsignedBigInteger('followers')->default(0);
$table->unsignedBigInteger('following')->default(0);
$table->unsignedBigInteger('posts_count')->default(0);
$table->decimal('engagement_rate', 5, 2)->default(0);
$table->unsignedBigInteger('avg_views')->default(0);
$table->unsignedBigInteger('avg_likes')->default(0);
$table->unsignedBigInteger('avg_comments')->default(0);
$table->date('recorded_date');
$table->json('raw_data')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('social_stats');
}
};

View File

@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}

2141
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@alpinejs/sort": "^3.15.9",
"@tailwindcss/vite": "^4.0.0",
"alpinejs": "^3.15.9",
"autoprefixer": "^10.4.27",
"axios": ">=1.11.0 <=1.14.0",
"concurrently": "^9.0.1",
"daisyui": "^5.5.19",
"laravel-vite-plugin": "^3.0.0",
"tailwindcss": "^4.2.2",
"vite": "^8.0.0"
}
}

36
phpunit.xml Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
public/.htaccess Normal file
View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

0
public/favicon.ico Normal file
View File

20
public/index.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

122
resources/css/app.css Normal file
View File

@@ -0,0 +1,122 @@
@import 'tailwindcss';
@plugin "daisyui";
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Nunito', ui-sans-serif, system-ui, sans-serif;
--color-sakura: #FFB7C5;
--color-sakura-light: #FFD6E0;
--color-sakura-dark: #FF8FAD;
--color-cream: #FFF8F0;
--color-pastel-yellow: #FDFD96;
--color-lavender: #E8D5F5;
--color-mint: #B5EAD7;
--color-peach: #FFD5B8;
}
/* ── Base ─────────────────────────────────────────────────── */
body {
background-color: #FFF8F0;
min-height: 100vh;
}
* {
scrollbar-width: thin;
scrollbar-color: #FFB7C5 #FFF8F0;
}
/* ── Sidebar transition ───────────────────────────────────── */
aside {
will-change: transform;
}
/* ── Cat Card ─────────────────────────────────────────────── */
.cat-card {
@apply rounded-3xl shadow-sm bg-white border border-pink-100;
}
/* ── Bubbly Button ────────────────────────────────────────── */
.btn-sakura {
@apply rounded-full text-white font-semibold shadow-sm px-5 py-2
transition-all duration-150 inline-block cursor-pointer
text-center;
background-color: #FFB7C5;
border: none;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
.btn-sakura:hover {
background-color: #FF8FAD;
transform: translateY(-1px);
box-shadow: 0 4px 14px rgba(255, 143, 173, 0.4);
}
.btn-sakura:active {
transform: scale(0.96);
}
/* ── Soft Input ───────────────────────────────────────────── */
.input-soft {
@apply rounded-2xl border border-pink-200 px-4 py-2
focus:border-pink-400 focus:outline-none focus:ring-2 focus:ring-pink-200
w-full bg-white text-sm;
}
/* ── Kanban Column ────────────────────────────────────────── */
.kanban-col {
@apply rounded-3xl p-4 min-h-48 flex-1;
min-width: 240px;
}
/* ── Nav Items ────────────────────────────────────────────── */
.nav-item {
@apply flex items-center gap-3 px-4 py-3 rounded-2xl text-gray-600
hover:bg-pink-50 hover:text-pink-600 transition-all duration-150 font-medium;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
.nav-item.active {
@apply bg-pink-100 text-pink-600;
}
/* ── Stat Pill ────────────────────────────────────────────── */
.stat-pill {
@apply rounded-3xl shadow-sm bg-white border border-pink-100 p-4 flex flex-col gap-1;
}
/* ── Badge Sakura ─────────────────────────────────────────── */
.badge-sakura {
@apply rounded-full text-xs font-bold px-2 py-0.5 text-white;
background-color: #FFB7C5;
}
/* ── Badge variants ───────────────────────────────────────── */
.badge { @apply rounded-full text-xs font-semibold px-2.5 py-0.5 inline-block; }
.badge-ghost { @apply bg-gray-100 text-gray-500; }
.badge-info { @apply bg-blue-100 text-blue-600; }
.badge-success { @apply bg-green-100 text-green-600; }
.badge-error { @apply bg-red-100 text-red-600; }
.badge-warning { @apply bg-amber-100 text-amber-600; }
.badge-neutral { @apply bg-gray-100 text-gray-500; }
/* ── Table ────────────────────────────────────────────────── */
.table { @apply w-full border-collapse text-sm; }
.table th { @apply text-left py-3 px-4 text-xs font-bold text-gray-500 uppercase tracking-wide; }
.table td { @apply py-3 px-4 text-gray-700; }
/* ── Animations ───────────────────────────────────────────── */
@keyframes paw-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.paw-bounce { animation: paw-bounce 0.4s ease-in-out; }
/* ── Mobile touch targets ─────────────────────────────────── */
@media (max-width: 1023px) {
.nav-item { @apply py-3.5; }
button, a { min-height: 36px; }
}

57
resources/js/app.js Normal file
View File

@@ -0,0 +1,57 @@
import './bootstrap';
import Alpine from 'alpinejs';
import sort from '@alpinejs/sort';
Alpine.plugin(sort);
window.Alpine = Alpine;
Alpine.start();
// Meow sound toggle (stored in localStorage)
window.KolManager = {
meowEnabled: localStorage.getItem('meow') !== 'false',
toggleMeow() {
this.meowEnabled = !this.meowEnabled;
localStorage.setItem('meow', this.meowEnabled);
},
playMeow() {
if (!this.meowEnabled) return;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g);
g.connect(ctx.destination);
o.type = 'sine';
o.frequency.setValueAtTime(880, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(1200, ctx.currentTime + 0.05);
o.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.15);
g.gain.setValueAtTime(0.3, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
o.start(ctx.currentTime);
o.stop(ctx.currentTime + 0.3);
},
notify(msg) {
this.playMeow();
const el = document.createElement('div');
el.className = 'fixed top-4 right-4 z-50 cat-card px-5 py-3 flex items-center gap-2 shadow-lg border border-pink-200 animate-bounce';
el.innerHTML = `<span class="text-xl">🐱</span><span class="text-sm font-medium text-pink-700">${msg}</span>`;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3500);
},
};
// Flash message auto-dismiss
document.addEventListener('DOMContentLoaded', () => {
const flash = document.getElementById('flash-message');
if (flash) {
window.KolManager.playMeow();
setTimeout(() => {
flash.style.opacity = '0';
flash.style.transform = 'translateY(-10px)';
flash.style.transition = 'all 0.4s ease';
setTimeout(() => flash.remove(), 400);
}, 3000);
}
});

4
resources/js/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

View File

@@ -0,0 +1,105 @@
@extends('layouts.app')
@section('title', 'Add Client')
@section('page-title', 'Add New Client 🐾')
@section('page-subtitle', 'Fill in the contact details below')
@section('content')
<div class="max-w-2xl mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('clients.store') }}" method="POST" class="space-y-5">
@csrf
{{-- Basic Info --}}
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Basic Info</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Name <span class="text-pink-400">*</span></label>
<input type="text" name="name" value="{{ old('name') }}" class="input-soft" placeholder="Brand / Contact name" required>
@error('name') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
<input type="email" name="email" value="{{ old('email') }}" class="input-soft" placeholder="hello@brand.com">
@error('email') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Phone</label>
<input type="text" name="phone" value="{{ old('phone') }}" class="input-soft" placeholder="+62 812 xxxx">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Company</label>
<input type="text" name="company" value="{{ old('company') }}" class="input-soft" placeholder="Brand / Company name">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
<select name="status" class="input-soft">
@foreach($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', 'prospect') === $s->value ? 'selected' : '' }}>
{{ $s->label() }}
</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Social Handles --}}
<div>
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">📱 Social Handles</label>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">📸 Instagram</label>
<input type="text" name="ig_handle" value="{{ old('ig_handle') }}" class="input-soft" placeholder="@handle">
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">🎵 TikTok</label>
<input type="text" name="tiktok_handle" value="{{ old('tiktok_handle') }}" class="input-soft" placeholder="@handle">
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">▶️ YouTube</label>
<input type="text" name="youtube_handle" value="{{ old('youtube_handle') }}" class="input-soft" placeholder="Channel name">
</div>
</div>
</div>
{{-- Shipping Address --}}
<div>
<label class="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">📍 Shipping / PR Address</label>
<div class="grid grid-cols-2 gap-3">
<div class="col-span-2">
<input type="text" name="address_line1" value="{{ old('address_line1') }}" class="input-soft" placeholder="Street address">
</div>
<div class="col-span-2">
<input type="text" name="address_line2" value="{{ old('address_line2') }}" class="input-soft" placeholder="Apartment, suite, etc.">
</div>
<div>
<input type="text" name="city" value="{{ old('city') }}" class="input-soft" placeholder="City">
</div>
<div>
<input type="text" name="state" value="{{ old('state') }}" class="input-soft" placeholder="Province">
</div>
<div>
<input type="text" name="postal_code" value="{{ old('postal_code') }}" class="input-soft" placeholder="Postal code">
</div>
<div>
<input type="text" name="country" value="{{ old('country', 'Indonesia') }}" class="input-soft" placeholder="Country">
</div>
</div>
</div>
{{-- Notes --}}
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="3" class="input-soft resize-none" placeholder="Any extra notes...">{{ old('notes') }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">🐱 Save Client</button>
<a href="{{ route('clients.index') }}" class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">Cancel</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,108 @@
@extends('layouts.app')
@section('title', 'Edit ' . $client->name)
@section('page-title', 'Edit Client 🐾')
@section('page-subtitle', $client->name)
@section('content')
<div class="max-w-2xl mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('clients.update', $client) }}" method="POST" class="space-y-5">
@csrf @method('PUT')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Name <span class="text-pink-400">*</span></label>
<input type="text" name="name" value="{{ old('name', $client->name) }}" class="input-soft" required>
@error('name') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Email</label>
<input type="email" name="email" value="{{ old('email', $client->email) }}" class="input-soft">
@error('email') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Phone</label>
<input type="text" name="phone" value="{{ old('phone', $client->phone) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Company</label>
<input type="text" name="company" value="{{ old('company', $client->company) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
<select name="status" class="input-soft">
@foreach($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', $client->status->value) === $s->value ? 'selected' : '' }}>{{ $s->label() }}</option>
@endforeach
</select>
</div>
</div>
<div>
<h3 class="text-sm font-bold text-gray-600 mb-3">📱 Social Handles</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">📸 Instagram</label>
<input type="text" name="ig_handle" value="{{ old('ig_handle', $client->ig_handle) }}"
class="input-soft text-sm" placeholder="@handle">
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">🎵 TikTok</label>
<input type="text" name="tiktok_handle" value="{{ old('tiktok_handle', $client->tiktok_handle) }}"
class="input-soft text-sm" placeholder="@handle">
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">▶️ YouTube</label>
<input type="text" name="youtube_handle" value="{{ old('youtube_handle', $client->youtube_handle) }}"
class="input-soft text-sm">
</div>
</div>
</div>
<div>
<h3 class="text-sm font-bold text-gray-600 mb-3">📍 Shipping Address</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<input type="text" name="address_line1" value="{{ old('address_line1', $client->address_line1) }}"
class="input-soft" placeholder="Street address">
</div>
<div class="sm:col-span-2">
<input type="text" name="address_line2" value="{{ old('address_line2', $client->address_line2) }}"
class="input-soft" placeholder="Apt, suite...">
</div>
<div>
<input type="text" name="city" value="{{ old('city', $client->city) }}"
class="input-soft" placeholder="City">
</div>
<div>
<input type="text" name="state" value="{{ old('state', $client->state) }}"
class="input-soft" placeholder="Province">
</div>
<div>
<input type="text" name="postal_code" value="{{ old('postal_code', $client->postal_code) }}"
class="input-soft" placeholder="Postal code">
</div>
<div>
<input type="text" name="country" value="{{ old('country', $client->country) }}"
class="input-soft" placeholder="Country">
</div>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="3" class="input-soft resize-none">{{ old('notes', $client->notes) }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">💾 Save Changes</button>
<a href="{{ route('clients.show', $client) }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,92 @@
@extends('layouts.app')
@section('title', 'Clients')
@section('page-title', 'Clients / CRM 👥')
@section('page-subtitle', 'Manage your brand partners and contacts')
@section('content')
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<div class="relative">
<input type="text" placeholder="Search clients..." class="input-soft pl-9 text-sm"
oninput="filterClients(this.value)" id="search-input">
<span class="absolute left-3 top-2.5 text-gray-400 text-sm">🔍</span>
</div>
<a href="{{ route('clients.create') }}" class="btn-sakura text-sm text-center">+ Add Client</a>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4" id="clients-grid">
@forelse($clients as $client)
<div class="cat-card p-4 flex flex-col gap-3 hover:shadow-md transition-shadow duration-200"
data-name="{{ strtolower($client->name) }}">
<div class="flex items-start gap-3">
<div class="w-11 h-11 rounded-2xl flex items-center justify-center text-white font-bold text-base shadow-sm shrink-0"
style="background: linear-gradient(135deg, #FFB7C5, #E8D5F5);">
{{ $client->initials }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-bold text-gray-800 text-sm truncate">{{ $client->name }}</h3>
<span class="badge {{ $client->status->badgeClass() }} text-xs shrink-0">{{ $client->status->label() }}</span>
</div>
@if($client->company)
<div class="text-xs text-gray-400 truncate">{{ $client->company }}</div>
@endif
</div>
</div>
<div class="flex flex-wrap gap-1.5">
@if($client->ig_handle)
<span class="text-xs bg-pink-50 text-pink-600 px-2 py-0.5 rounded-full">📸 {{ $client->ig_handle }}</span>
@endif
@if($client->tiktok_handle)
<span class="text-xs bg-purple-50 text-purple-600 px-2 py-0.5 rounded-full">🎵 {{ $client->tiktok_handle }}</span>
@endif
@if($client->youtube_handle)
<span class="text-xs bg-red-50 text-red-600 px-2 py-0.5 rounded-full">▶️ {{ $client->youtube_handle }}</span>
@endif
</div>
<div class="flex gap-3 text-xs text-gray-500">
<span>📅 {{ $client->events_count }}</span>
<span>🧾 {{ $client->invoices_count }}</span>
<span>🎬 {{ $client->content_briefs_count }}</span>
</div>
@if($client->email || $client->city)
<div class="text-xs text-gray-400 space-y-0.5">
@if($client->email) <div class="truncate">✉️ {{ $client->email }}</div> @endif
@if($client->city) <div>📍 {{ $client->city }}{{ $client->country ? ', '.$client->country : '' }}</div> @endif
</div>
@endif
<div class="flex gap-2 pt-2 border-t border-pink-50">
<a href="{{ route('clients.show', $client) }}" class="btn-sakura text-xs py-1.5 flex-1 text-center">View</a>
<a href="{{ route('clients.edit', $client) }}" class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50">Edit</a>
<form action="{{ route('clients.destroy', $client) }}" method="POST" onsubmit="return confirm('Remove this client?')">
@csrf @method('DELETE')
<button class="text-xs py-1.5 px-3 rounded-full border border-red-100 text-red-400 hover:bg-red-50">Del</button>
</form>
</div>
</div>
@empty
<div class="col-span-full text-center py-16 text-gray-400">
<span class="text-5xl block mb-3">🐱</span>
<p class="text-base font-medium">No clients yet!</p>
<a href="{{ route('clients.create') }}" class="btn-sakura mt-4 inline-block">Add your first client</a>
</div>
@endforelse
</div>
{{ $clients->links() }}
@push('scripts')
<script>
function filterClients(query) {
document.querySelectorAll('#clients-grid > div[data-name]').forEach(card => {
card.style.display = card.dataset.name.includes(query.toLowerCase()) ? '' : 'none';
});
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,138 @@
@extends('layouts.app')
@section('title', $client->name)
@section('page-title', $client->name)
@section('page-subtitle', $client->company ?? $client->email ?? 'Client Profile')
@section('content')
<div class="max-w-5xl mx-auto">
{{-- Profile Header --}}
<div class="cat-card p-4 mb-4">
<div class="flex items-start gap-4">
<div class="w-14 h-14 rounded-3xl flex items-center justify-center text-white font-bold text-xl shadow shrink-0"
style="background: linear-gradient(135deg, #FFB7C5, #E8D5F5);">
{{ $client->initials }}
</div>
<div class="flex-1 min-w-0">
<h2 class="text-lg font-bold text-gray-800 leading-tight">{{ $client->name }}</h2>
@if($client->company) <p class="text-sm text-gray-500">{{ $client->company }}</p> @endif
<div class="flex gap-2 mt-2 flex-wrap">
<span class="badge {{ $client->status->badgeClass() }}">{{ $client->status->label() }}</span>
@if($client->ig_handle)
<span class="text-xs bg-pink-50 text-pink-600 px-2 py-1 rounded-full">📸 {{ $client->ig_handle }}</span>
@endif
@if($client->tiktok_handle)
<span class="text-xs bg-purple-50 text-purple-600 px-2 py-1 rounded-full">🎵 {{ $client->tiktok_handle }}</span>
@endif
@if($client->youtube_handle)
<span class="text-xs bg-red-50 text-red-600 px-2 py-1 rounded-full">▶️ {{ $client->youtube_handle }}</span>
@endif
</div>
</div>
</div>
{{-- Action buttons below on mobile, inline on larger screens --}}
<div class="flex gap-2 mt-3">
<a href="{{ route('clients.edit', $client) }}" class="btn-sakura text-sm px-4 py-2">Edit</a>
<a href="{{ route('invoices.create') }}?client={{ $client->id }}"
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold">
🧾 New Invoice
</a>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
{{-- Contact Info --}}
<div class="cat-card p-4">
<h3 class="font-bold text-gray-700 mb-3 text-sm flex items-center gap-2">📋 Contact Info</h3>
<div class="space-y-2 text-sm">
@if($client->email)
<div class="flex gap-2 items-start">
<span class="text-gray-400 shrink-0">✉️</span>
<a href="mailto:{{ $client->email }}" class="text-pink-500 hover:underline break-all">{{ $client->email }}</a>
</div>
@endif
@if($client->phone)
<div class="flex gap-2 items-start">
<span class="text-gray-400 shrink-0">📞</span>
<span>{{ $client->phone }}</span>
</div>
@endif
@if($client->full_address)
<div class="flex gap-2 items-start">
<span class="text-gray-400 shrink-0">📍</span>
<span class="text-gray-600">{{ $client->full_address }}</span>
</div>
@endif
@if($client->notes)
<div class="mt-3 pt-3 border-t border-pink-50">
<p class="text-xs font-semibold text-gray-500 mb-1">Notes</p>
<p class="text-gray-600 text-xs">{{ $client->notes }}</p>
</div>
@endif
@if(!$client->email && !$client->phone && !$client->full_address && !$client->notes)
<p class="text-xs text-gray-400">No contact info yet.</p>
@endif
</div>
</div>
{{-- Recent Events --}}
<div class="cat-card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-700 text-sm flex items-center gap-2">📅 Events</h3>
<a href="{{ route('schedule.index') }}" class="text-xs text-pink-500">All →</a>
</div>
@forelse($client->events as $event)
<div class="py-2 border-b border-pink-50 last:border-0 text-sm">
<div class="font-medium text-gray-700 truncate">{{ $event->title }}</div>
<div class="text-xs text-gray-400">{{ $event->start_time->format('d M Y, H:i') }}</div>
</div>
@empty
<p class="text-xs text-gray-400">No events yet.</p>
@endforelse
</div>
{{-- Recent Invoices --}}
<div class="cat-card p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-700 text-sm flex items-center gap-2">🧾 Invoices</h3>
<a href="{{ route('invoices.index') }}" class="text-xs text-pink-500">All →</a>
</div>
@forelse($client->invoices as $invoice)
<div class="py-2 border-b border-pink-50 last:border-0 text-sm flex justify-between gap-2">
<div class="min-w-0">
<div class="font-medium text-gray-700 truncate">{{ $invoice->invoice_number }}</div>
<div class="text-xs text-gray-400">Due {{ $invoice->due_date->format('d M Y') }}</div>
</div>
<div class="text-right shrink-0">
<div class="font-bold text-gray-700 text-xs">{{ $invoice->formatted_total }}</div>
<span class="badge {{ $invoice->status->badgeClass() }} badge-xs">{{ $invoice->status->label() }}</span>
</div>
</div>
@empty
<p class="text-xs text-gray-400">No invoices yet.</p>
@endforelse
</div>
</div>
{{-- Content Briefs --}}
@if($client->contentBriefs->isNotEmpty())
<div class="cat-card p-4 mt-4">
<h3 class="font-bold text-gray-700 mb-3 text-sm flex items-center gap-2">🎬 Content Briefs</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
@foreach($client->contentBriefs as $brief)
<a href="{{ route('content.show', $brief) }}"
class="bg-pink-50 rounded-2xl p-3 hover:bg-pink-100 transition-colors block">
<div class="text-xs font-bold text-pink-600 flex items-center gap-1">
{{ $brief->platform_icon }} {{ ucfirst($brief->platform) }}
</div>
<div class="font-semibold text-gray-700 text-sm truncate mt-1">{{ $brief->title }}</div>
<div class="text-xs text-gray-400 mt-0.5">{{ $brief->status->label() }}</div>
</a>
@endforeach
</div>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,107 @@
@extends('layouts.app')
@section('title', 'New Content Brief')
@section('page-title', 'New Content Brief 🎬')
@section('content')
<div class="max-w-2xl mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('content.store') }}" method="POST" class="space-y-4">
@csrf
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Title <span class="text-pink-400">*</span></label>
<input type="text" name="title" value="{{ old('title') }}" class="input-soft" required
placeholder="e.g. GRWM ft. Brand X Skincare">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Platform <span class="text-pink-400">*</span></label>
<select name="platform" class="input-soft" required>
<option value="instagram">📸 Instagram</option>
<option value="tiktok">🎵 TikTok</option>
<option value="youtube">▶️ YouTube</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Content Type</label>
<input type="text" name="content_type" value="{{ old('content_type') }}" class="input-soft"
placeholder="Reel, Short, GRWM, Story...">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
<select name="status" class="input-soft">
@foreach($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', request('status', 'drafting')) === $s->value ? 'selected' : '' }}>
{{ $s->icon() }} {{ $s->label() }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— None —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}">{{ $client->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Target Date</label>
<input type="date" name="target_date" value="{{ old('target_date') }}" class="input-soft">
</div>
</div>
{{-- Storyline Maker --}}
<div class="bg-pink-50 rounded-2xl p-4 space-y-3">
<h3 class="font-bold text-gray-700 flex items-center gap-2 text-sm">✍️ Storyline Maker</h3>
<div>
<label class="block text-xs font-semibold text-pink-600 mb-1">🎣 HOOK — Open with a bang!</label>
<textarea name="hook" rows="2" class="input-soft text-sm resize-none bg-white"
placeholder="e.g. 'POV: you finally found the moisturizer that doesn't break you out...'">{{ old('hook') }}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-pink-600 mb-1">📖 BODY — Deliver the story</label>
<textarea name="body" rows="3" class="input-soft text-sm resize-none bg-white"
placeholder="The main content — what happens, product demo, value...">{{ old('body') }}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-pink-600 mb-1">📣 CTA — Call to Action</label>
<textarea name="cta" rows="2" class="input-soft text-sm resize-none bg-white"
placeholder="e.g. 'Link in bio to shop! Use code KITTY for 15% off'">{{ old('cta') }}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">📝 Full Script (optional)</label>
<textarea name="script" rows="4" class="input-soft text-sm resize-none bg-white"
placeholder="Complete spoken script, timestamps, shot list...">{{ old('script') }}</textarea>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Caption</label>
<textarea name="caption" rows="3" class="input-soft resize-none"
placeholder="Post caption text...">{{ old('caption') }}</textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Hashtags</label>
<input type="text" name="hashtags" value="{{ old('hashtags') }}" class="input-soft"
placeholder="#skincare #grwm #fyp">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="2" class="input-soft resize-none"
placeholder="Any extra notes, brand requirements...">{{ old('notes') }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">🎬 Create Brief</button>
<a href="{{ route('content.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,103 @@
@extends('layouts.app')
@section('title', 'Edit Brief')
@section('page-title', 'Edit Content Brief 🎬')
@section('content')
<div class="max-w-2xl mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('content.update', $brief) }}" method="POST" class="space-y-4">
@csrf @method('PUT')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Title</label>
<input type="text" name="title" value="{{ old('title', $brief->title) }}" class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Platform</label>
<select name="platform" class="input-soft">
@foreach(['instagram','tiktok','youtube'] as $p)
<option value="{{ $p }}" {{ old('platform', $brief->platform) === $p ? 'selected' : '' }}>{{ ucfirst($p) }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Content Type</label>
<input type="text" name="content_type" value="{{ old('content_type', $brief->content_type) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
<select name="status" class="input-soft">
@foreach($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', $brief->status->value) === $s->value ? 'selected' : '' }}>{{ $s->icon() }} {{ $s->label() }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— None —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}" {{ old('client_id', $brief->client_id) == $client->id ? 'selected' : '' }}>{{ $client->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Target Date</label>
<input type="date" name="target_date" value="{{ old('target_date', $brief->target_date?->toDateString()) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Posted Date</label>
<input type="date" name="posted_date" value="{{ old('posted_date', $brief->posted_date?->toDateString()) }}" class="input-soft">
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Posted URL</label>
<input type="url" name="posted_url" value="{{ old('posted_url', $brief->posted_url) }}" class="input-soft" placeholder="https://...">
</div>
</div>
<div class="bg-pink-50 rounded-2xl p-4 space-y-3">
<h3 class="font-bold text-gray-700 text-sm">✍️ Storyline</h3>
<div>
<label class="text-xs font-semibold text-pink-600 mb-1 block">🎣 Hook</label>
<textarea name="hook" rows="2" class="input-soft text-sm resize-none bg-white">{{ old('hook', $brief->hook) }}</textarea>
</div>
<div>
<label class="text-xs font-semibold text-pink-600 mb-1 block">📖 Body</label>
<textarea name="body" rows="3" class="input-soft text-sm resize-none bg-white">{{ old('body', $brief->body) }}</textarea>
</div>
<div>
<label class="text-xs font-semibold text-pink-600 mb-1 block">📣 CTA</label>
<textarea name="cta" rows="2" class="input-soft text-sm resize-none bg-white">{{ old('cta', $brief->cta) }}</textarea>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 mb-1 block">📝 Script</label>
<textarea name="script" rows="4" class="input-soft text-sm resize-none bg-white">{{ old('script', $brief->script) }}</textarea>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Caption</label>
<textarea name="caption" rows="3" class="input-soft resize-none">{{ old('caption', $brief->caption) }}</textarea>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Hashtags</label>
<input type="text" name="hashtags" value="{{ old('hashtags', $brief->hashtags) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="2" class="input-soft resize-none">{{ old('notes', $brief->notes) }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">💾 Save Changes</button>
<a href="{{ route('content.show', $brief) }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,112 @@
@extends('layouts.app')
@section('title', 'Content Board')
@section('page-title', 'Content Board 🎬')
@section('page-subtitle', 'Drag cards between columns to update status')
@section('content')
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<div class="flex gap-2 flex-wrap">
@foreach(['instagram','tiktok','youtube'] as $p)
<a href="{{ request()->fullUrlWithQuery(['platform' => $p]) }}"
class="px-3 py-1.5 rounded-full text-xs font-semibold transition-colors
{{ request('platform') === $p ? 'text-white' : 'bg-white text-gray-500 hover:bg-pink-50 border border-pink-100' }}"
style="{{ request('platform') === $p ? 'background:#FFB7C5' : '' }}">
{{ ['instagram'=>'📸','tiktok'=>'🎵','youtube'=>'▶️'][$p] }} {{ ucfirst($p) }}
</a>
@endforeach
@if(request('platform'))
<a href="{{ route('content.index') }}" class="px-3 py-1.5 rounded-full text-xs text-gray-400 hover:text-gray-600 border border-gray-200 bg-white">✕ Clear</a>
@endif
</div>
<a href="{{ route('content.create') }}" class="btn-sakura text-sm shrink-0">+ New Brief</a>
</div>
{{--
Mobile : grid grid-cols-1 → columns stack vertically, each full width
Desktop : flex flex-nowrap → columns in a single horizontal row, each flex-1
x-sort:group is on the INNER items div (not the column div) to avoid
SortableJS mutating the outer layout container.
--}}
<div class="grid grid-cols-1 gap-4 lg:flex lg:flex-nowrap lg:gap-4"
x-data="kanban()">
@foreach($columns as $col)
@php $status = $col['status']; @endphp
{{-- Column shell — layout only, NOT the sort target --}}
<div class="{{ $status->colorClass() }} flex flex-col w-full lg:flex-1 lg:min-w-36 rounded-2xl p-3"
data-status="{{ $status->value }}">
{{-- Column header --}}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-1.5">
<span class="text-base">{{ $status->icon() }}</span>
<span class="font-bold text-gray-700 text-sm">{{ $status->label() }}</span>
</div>
<span class="text-xs bg-white rounded-full px-2 py-0.5 font-bold text-gray-500 shadow-sm">{{ count($col['items']) }}</span>
</div>
{{-- Sortable items container --}}
<div class="flex flex-col gap-2 flex-1 min-h-12"
x-sort:group="kanban"
@sort="moved($event, '{{ $status->value }}')"
data-col-status="{{ $status->value }}">
@foreach($col['items'] as $brief)
<div class="bg-white rounded-2xl p-3 shadow-sm border border-pink-50 select-none
cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
x-sort:item="{{ $brief->id }}"
data-id="{{ $brief->id }}">
<div class="flex items-start gap-2 mb-1.5">
<span class="text-sm shrink-0">{{ $brief->platform_icon }}</span>
<a href="{{ route('content.show', $brief) }}"
class="font-semibold text-gray-800 text-xs leading-tight hover:text-pink-500 line-clamp-2 flex-1">
{{ $brief->title }}
</a>
</div>
@if($brief->client)
<div class="text-xs text-gray-400 mb-1">👥 {{ $brief->client->name }}</div>
@endif
@if($brief->target_date)
<div class="text-xs {{ $brief->target_date->isPast() && $brief->status->value !== 'posted' ? 'text-red-400 font-bold' : 'text-gray-400' }}">
📅 {{ $brief->target_date->format('d M') }}
</div>
@endif
<div class="flex justify-end mt-1.5">
<a href="{{ route('content.edit', $brief) }}" class="text-xs text-gray-400 hover:text-pink-500">Edit</a>
</div>
</div>
@endforeach
</div>
{{-- Add button --}}
<a href="{{ route('content.create') }}?status={{ $status->value }}"
class="mt-3 text-xs text-gray-400 hover:text-pink-500 text-center py-2
border-2 border-dashed border-pink-100 rounded-2xl hover:border-pink-300 transition-colors block">
+ Add
</a>
</div>
@endforeach
</div>
@push('scripts')
<script>
function kanban() {
return {
moved(event, targetStatus) {
const id = event.item?.dataset?.id;
if (!id) return;
fetch(`/content/${id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ status: targetStatus }),
}).then(r => { if (r.ok) window.KolManager.notify('Status updated! 🎬'); });
},
};
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,93 @@
@extends('layouts.app')
@section('title', $brief->title)
@section('page-title', $brief->title)
@section('page-subtitle', $brief->platform_icon . ' ' . ucfirst($brief->platform) . ' · ' . $brief->status->label())
@section('content')
<div class="max-w-3xl mx-auto">
<div class="flex gap-3 mb-5">
<a href="{{ route('content.edit', $brief) }}" class="btn-sakura">Edit</a>
<form action="{{ route('content.destroy', $brief) }}" method="POST" onsubmit="return confirm('Delete this brief?')">
@csrf @method('DELETE')
<button class="btn btn-ghost rounded-full text-error">Delete</button>
</form>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
{{-- Main Brief --}}
<div class="lg:col-span-2 space-y-4">
{{-- Storyline --}}
@foreach([['🎣', 'Hook', $brief->hook], ['📖', 'Body', $brief->body], ['📣', 'CTA', $brief->cta], ['📝', 'Script', $brief->script]] as [$icon, $label, $value])
@if($value)
<div class="cat-card p-5">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">{{ $icon }}</span>
<h3 class="font-bold text-gray-700">{{ $label }}</h3>
</div>
<p class="text-sm text-gray-600 whitespace-pre-wrap leading-relaxed">{{ $value }}</p>
</div>
@endif
@endforeach
@if($brief->caption)
<div class="cat-card p-5">
<h3 class="font-bold text-gray-700 mb-2">Caption</h3>
<p class="text-sm text-gray-600 whitespace-pre-wrap">{{ $brief->caption }}</p>
@if($brief->hashtags)
<div class="mt-2 text-xs text-pink-500">{{ $brief->hashtags }}</div>
@endif
</div>
@endif
</div>
{{-- Sidebar --}}
<div class="space-y-4">
<div class="cat-card p-5 space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Platform</span>
<span class="font-medium">{{ $brief->platform_icon }} {{ ucfirst($brief->platform) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Type</span>
<span class="font-medium">{{ $brief->content_type ?? '—' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-500">Status</span>
<span class="badge {{ $brief->status->badgeClass() }}">{{ $brief->status->label() }}</span>
</div>
@if($brief->client)
<div class="flex justify-between">
<span class="text-gray-500">Client</span>
<a href="{{ route('clients.show', $brief->client) }}" class="font-medium text-pink-500 hover:underline">{{ $brief->client->name }}</a>
</div>
@endif
@if($brief->target_date)
<div class="flex justify-between">
<span class="text-gray-500">Target</span>
<span class="font-medium">{{ $brief->target_date->format('d M Y') }}</span>
</div>
@endif
@if($brief->posted_date)
<div class="flex justify-between">
<span class="text-gray-500">Posted</span>
<span class="font-medium text-green-600">{{ $brief->posted_date->format('d M Y') }}</span>
</div>
@endif
@if($brief->posted_url)
<div>
<a href="{{ $brief->posted_url }}" target="_blank" class="btn-sakura text-xs px-4 py-1.5 w-full text-center block">🔗 View Post</a>
</div>
@endif
</div>
@if($brief->notes)
<div class="cat-card p-5">
<h4 class="font-bold text-gray-600 text-xs uppercase mb-2">Notes</h4>
<p class="text-sm text-gray-500">{{ $brief->notes }}</p>
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,139 @@
@extends('layouts.app')
@section('title', 'Dashboard')
@section('page-title', 'Dashboard 🐾')
@section('page-subtitle', today()->format('l, d F Y'))
@section('content')
{{-- ── Today's Schedule (TOP) ───────────────────────────────── --}}
<div class="cat-card p-4 lg:p-6 mb-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm lg:text-base font-bold text-gray-700 flex items-center gap-2">
<span>📅</span> Today's Schedule
</h2>
<a href="{{ route('schedule.index') }}" class="text-xs text-pink-500 hover:underline font-medium">View all →</a>
</div>
@forelse($todayActivities as $activity)
<div class="flex items-start gap-3 py-3 border-b border-pink-50 last:border-0">
<div class="w-1 h-10 rounded-full shrink-0 mt-0.5" style="background-color: {{ $activity['color'] }};"></div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 flex-wrap">
@if($activity['priority'])<span class="text-amber-400 text-xs">⭐</span>@endif
<span class="text-base shrink-0">{{ $activity['icon'] }}</span>
<span class="font-semibold text-gray-800 text-sm truncate">{{ $activity['title'] }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-pink-50 text-pink-600 shrink-0 capitalize">{{ $activity['label'] }}</span>
</div>
<div class="text-xs text-gray-500 mt-0.5">{{ $activity['subtitle'] }}</div>
</div>
<span class="badge {{ $activity['badge_class'] }} shrink-0 text-xs">{{ $activity['badge'] }}</span>
</div>
@empty
<div class="text-center py-6 text-gray-400">
<span class="text-4xl block mb-1">😸</span>
<p class="text-sm">No activities today — enjoy your day!</p>
<div class="flex gap-2 justify-center mt-3">
<a href="{{ route('schedule.index') }}" class="text-xs text-pink-500 hover:underline">Add event</a>
<span class="text-xs text-gray-300">·</span>
<a href="{{ route('invoices.create') }}" class="text-xs text-pink-500 hover:underline">Create invoice</a>
<span class="text-xs text-gray-300">·</span>
<a href="{{ route('content.create') }}" class="text-xs text-pink-500 hover:underline">New brief</a>
</div>
</div>
@endforelse
</div>
{{-- ── Stat Pills (BELOW schedule) ─────────────────────────── --}}
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="stat-pill">
<div class="text-2xl lg:text-3xl font-extrabold text-pink-500">{{ $stats['total_clients'] }}</div>
<div class="text-xs lg:text-sm font-semibold text-gray-600">Total Clients</div>
<div class="text-xs text-gray-400">{{ $stats['active_clients'] }} active</div>
</div>
<div class="stat-pill">
<div class="text-2xl lg:text-3xl font-extrabold text-purple-500">{{ $stats['upcoming_events'] }}</div>
<div class="text-xs lg:text-sm font-semibold text-gray-600">Upcoming Events</div>
<div class="text-xs text-gray-400">Next 3 months</div>
</div>
<div class="stat-pill">
<div class="text-2xl lg:text-3xl font-extrabold text-amber-500">{{ $stats['pending_invoices'] }}</div>
<div class="text-xs lg:text-sm font-semibold text-gray-600">Pending Invoices</div>
<div class="text-xs text-gray-400">Awaiting payment</div>
</div>
<div class="stat-pill">
<div class="text-lg lg:text-2xl font-extrabold text-emerald-500 leading-tight">
Rp {{ number_format($stats['total_revenue'], 0, ',', '.') }}
</div>
<div class="text-xs lg:text-sm font-semibold text-gray-600">Revenue (Paid)</div>
<div class="text-xs text-gray-400">All time</div>
</div>
</div>
{{-- ── Bottom Row ───────────────────────────────────────────── --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
{{-- Social Stats --}}
<div class="cat-card p-4 lg:p-5">
<h2 class="text-sm font-bold text-gray-700 flex items-center gap-2 mb-3">
<span>📱</span> Social Snapshot
</h2>
@forelse($socialStats as $stat)
<div class="flex items-center justify-between py-2 border-b border-pink-50 last:border-0">
<div class="flex items-center gap-2">
<span class="text-xl">{{ $stat->platform_icon }}</span>
<div>
<div class="text-xs font-semibold text-gray-700">{{ ucfirst($stat->platform) }}</div>
<div class="text-xs text-gray-400">{{ $stat->handle ?? 'Not set' }}</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-gray-800 text-sm">{{ $stat->formatted_followers }}</div>
<div class="text-xs text-gray-400">{{ $stat->engagement_rate }}% eng.</div>
</div>
</div>
@empty
<p class="text-xs text-gray-400 text-center py-3">
No stats yet.
<a href="{{ route('social-hub.index') }}" class="text-pink-500 hover:underline">Add stats →</a>
</p>
@endforelse
</div>
{{-- Recent Invoices --}}
<div class="cat-card p-4 lg:p-5">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-bold text-gray-700 flex items-center gap-2"><span>🧾</span> Recent Invoices</h2>
<a href="{{ route('invoices.index') }}" class="text-xs text-pink-500 hover:underline">All →</a>
</div>
@forelse($latestInvoices as $invoice)
<div class="flex items-center justify-between py-2 border-b border-pink-50 last:border-0">
<div class="min-w-0">
<div class="text-xs font-semibold text-gray-700 truncate">{{ $invoice->invoice_number }}</div>
<div class="text-xs text-gray-400 truncate">{{ $invoice->client?->name ?? 'No client' }}</div>
</div>
<div class="text-right shrink-0 ml-2">
<div class="text-xs font-bold text-gray-700">{{ $invoice->formatted_total }}</div>
<span class="badge {{ $invoice->status->badgeClass() }} text-xs">{{ $invoice->status->label() }}</span>
</div>
</div>
@empty
<p class="text-xs text-gray-400 text-center py-3">No invoices yet.</p>
@endforelse
</div>
{{-- Content Progress --}}
<div class="cat-card p-4 lg:p-5">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-bold text-gray-700 flex items-center gap-2"><span>🎬</span> Content Pipeline</h2>
<a href="{{ route('content.index') }}" class="text-xs text-pink-500 hover:underline">Board →</a>
</div>
<div class="flex items-center gap-3 py-3">
<span class="text-3xl font-extrabold text-purple-500">{{ $stats['content_in_progress'] }}</span>
<span class="text-sm text-gray-600">pieces currently in progress</span>
</div>
<a href="{{ route('content.create') }}" class="btn-sakura text-xs w-full text-center mt-2">+ New Brief</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,148 @@
@extends('layouts.app')
@section('title', 'Files')
@section('page-title', 'File Manager 📁')
@section('page-subtitle', 'Brand assets, logos, and media files')
@section('content')
{{-- ── Upload Form (collapsible on mobile) ─────────────────── --}}
<div class="cat-card p-4 mb-4" x-data="{ open: false }">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-gray-700 flex items-center gap-2">📤 Upload File</h3>
<button class="btn-sakura text-xs px-4 py-1.5" @click="open = !open"
x-text="open ? '✕ Close' : '+ Upload'"></button>
</div>
<form x-show="open" x-transition
action="{{ route('files.store') }}" method="POST" enctype="multipart/form-data"
class="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
@csrf
<div class="sm:col-span-3">
<label class="block text-xs font-semibold text-gray-600 mb-1">
File <span class="text-pink-400">*</span>
<span class="text-gray-400 font-normal ml-1">Max 50 MB</span>
</label>
<input type="file" name="file" required
class="w-full text-sm text-gray-600
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
hover:file:bg-pink-100
cursor-pointer">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Folder</label>
<input type="text" name="folder" value="general" class="input-soft text-sm"
list="folder-list" placeholder="brand-assets, logos...">
<datalist id="folder-list">
@foreach($folders as $f)
<option value="{{ $f }}">
@endforeach
<option value="brand-assets">
<option value="logos">
<option value="videos">
<option value="photos">
<option value="documents">
</datalist>
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Client (optional)</label>
<select name="client_id" class="input-soft text-sm">
<option value="">— None —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}">{{ $client->name }}</option>
@endforeach
</select>
</div>
<div class="sm:flex sm:items-end">
<button type="submit" class="btn-sakura w-full py-2.5">📤 Upload</button>
</div>
</form>
</div>
{{-- ── Folder Filter Chips ──────────────────────────────────── --}}
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 -mx-4 px-4 lg:mx-0 lg:px-0">
<a href="{{ route('files.index') }}"
class="shrink-0 px-3 py-1.5 rounded-full text-xs font-semibold transition-colors
{{ !request('folder') || request('folder') === 'all'
? 'text-white' : 'bg-white text-gray-500 border border-pink-100 hover:bg-pink-50' }}"
style="{{ !request('folder') || request('folder') === 'all' ? 'background:#FFB7C5' : '' }}">
All
</a>
@foreach($folders as $f)
<a href="{{ request()->fullUrlWithQuery(['folder' => $f]) }}"
class="shrink-0 px-3 py-1.5 rounded-full text-xs font-semibold capitalize transition-colors
{{ request('folder') === $f
? 'text-white' : 'bg-white text-gray-500 border border-pink-100 hover:bg-pink-50' }}"
style="{{ request('folder') === $f ? 'background:#FFB7C5' : '' }}">
{{ $f }}
</a>
@endforeach
</div>
{{-- ── File Grid ────────────────────────────────────────────── --}}
@forelse($files as $file)
@if($loop->first)
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
@endif
<div class="cat-card p-3 flex flex-col gap-2">
{{-- Preview / Icon --}}
@if($file->is_image)
<div class="w-full aspect-square rounded-2xl overflow-hidden bg-pink-50">
<img src="{{ $file->url }}" alt="{{ $file->name }}"
class="w-full h-full object-cover">
</div>
@else
<div class="w-full aspect-square rounded-2xl bg-pink-50 flex items-center justify-center text-4xl">
{{ $file->icon }}
</div>
@endif
{{-- Info --}}
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-700 truncate" title="{{ $file->original_name }}">
{{ $file->name ?: $file->original_name }}
</p>
<p class="text-xs text-gray-400">{{ $file->human_size }}</p>
@if($file->client)
<p class="text-xs text-pink-500 truncate">👥 {{ $file->client->name }}</p>
@endif
<span class="text-xs bg-pink-50 text-pink-600 px-2 py-0.5 rounded-full capitalize inline-block mt-0.5">
{{ $file->folder }}
</span>
</div>
{{-- Actions --}}
<div class="flex gap-1.5 mt-auto">
<a href="{{ $file->url }}" target="_blank"
class="flex-1 text-center text-xs py-1.5 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50 font-medium">
View
</a>
<form action="{{ route('files.destroy', $file) }}" method="POST"
onsubmit="return confirm('Delete this file?')">
@csrf @method('DELETE')
<button class="text-xs py-1.5 px-3 rounded-full border border-red-100 text-red-400 hover:bg-red-50">
✕
</button>
</form>
</div>
</div>
@if($loop->last)
</div>
@endif
@empty
<div class="text-center py-16 text-gray-400">
<span class="text-5xl block mb-3">📁</span>
<p class="text-sm">No files yet.</p>
<button onclick="document.querySelector('[\\@click=\\'open = !open\\']').click()"
class="btn-sakura mt-4 inline-block text-sm">Upload your first file</button>
</div>
@endforelse
<div class="mt-4">{{ $files->links() }}</div>
@endsection

View File

@@ -0,0 +1,170 @@
@extends('layouts.app')
@section('title', 'New Invoice')
@section('page-title', 'Create Invoice 🧾')
@section('page-subtitle', 'Fill in the invoice details')
@section('content')
<div class="max-w-3xl mx-auto">
<div class="cat-card p-4 lg:p-8" x-data="invoiceForm()">
<form action="{{ route('invoices.store') }}" method="POST">
@csrf
{{-- Header fields --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Invoice Title <span class="text-pink-400">*</span></label>
<input type="text" name="title" value="{{ old('title') }}" class="input-soft" required
placeholder="e.g. Instagram Collaboration - Brand X">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— No client —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}" {{ request('client') == $client->id ? 'selected' : '' }}>
{{ $client->name }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Currency</label>
<select name="currency" class="input-soft">
<option value="IDR" selected>IDR (Rupiah)</option>
<option value="USD">USD (Dollar)</option>
<option value="SGD">SGD</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Issue Date</label>
<input type="date" name="issue_date" value="{{ old('issue_date', today()->toDateString()) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Due Date</label>
<input type="date" name="due_date"
value="{{ old('due_date', today()->addDays(14)->toDateString()) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Tax Rate (%)</label>
<input type="number" name="tax_rate" x-model="taxRate" @input="recalc()"
value="{{ old('tax_rate', 11) }}" class="input-soft" step="0.01">
</div>
</div>
{{-- Line Items --}}
<div class="mb-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-700">Line Items</h3>
<button type="button" @click="addItem()" class="btn-sakura text-xs py-1.5 px-4">+ Add Item</button>
</div>
<div class="space-y-2">
<template x-for="(item, index) in items" :key="index">
<div class="bg-pink-50 rounded-2xl p-3">
{{-- Description full width --}}
<input type="text" :name="`items[${index}][description]`"
x-model="item.description"
class="input-soft text-sm mb-2 bg-white"
placeholder="Service description" required>
{{-- Qty / Price / Total in a row --}}
<div class="flex gap-2 items-center">
<div class="w-16 shrink-0">
<label class="text-xs text-gray-400 block mb-0.5">Qty</label>
<input type="number" :name="`items[${index}][quantity]`"
x-model.number="item.quantity" @input="recalc()"
class="input-soft text-sm text-center bg-white px-2" min="1">
</div>
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-0.5">Unit Price</label>
<input type="number" :name="`items[${index}][unit_price]`"
x-model.number="item.unit_price" @input="recalc()"
class="input-soft text-sm bg-white" min="0" step="1000"
placeholder="0">
</div>
<div class="w-24 shrink-0 text-right">
<label class="text-xs text-gray-400 block mb-0.5">Total</label>
<div class="text-sm font-bold text-gray-700 pt-2">
Rp <span x-text="formatNum(item.quantity * item.unit_price)"></span>
</div>
</div>
<button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button>
</div>
</div>
</template>
</div>
</div>
{{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
</div>
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Tax (<span x-text="taxRate"></span>%)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
</div>
<div class="flex justify-between text-base font-bold py-2 mt-1 border-t border-pink-200">
<span class="text-gray-700">Total</span>
<span class="text-pink-600">Rp <span x-text="formatNum(total)"></span></span>
</div>
</div>
{{-- Bank Info --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Bank Name</label>
<input type="text" name="bank_name" value="{{ old('bank_name') }}"
class="input-soft text-sm" placeholder="BCA, Mandiri...">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Number</label>
<input type="text" name="bank_account" value="{{ old('bank_account') }}"
class="input-soft text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Holder</label>
<input type="text" name="bank_holder" value="{{ old('bank_holder') }}"
class="input-soft text-sm">
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="2" class="input-soft resize-none"
placeholder="Payment terms, etc.">{{ old('notes') }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="submit" class="btn-sakura flex-1 py-3">🧾 Create Invoice</button>
<a href="{{ route('invoices.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@push('scripts')
<script>
function invoiceForm() {
return {
items: [{ description: '', quantity: 1, unit_price: 0 }],
taxRate: 11,
get subtotal() { return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); },
get taxAmount() { return Math.round(this.subtotal * this.taxRate / 100); },
get total() { return this.subtotal + this.taxAmount; },
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0 }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() {},
formatNum(n) { return Number(n).toLocaleString('id-ID'); },
};
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,164 @@
@extends('layouts.app')
@section('title', 'Edit Invoice')
@section('page-title', 'Edit Invoice 🧾')
@section('page-subtitle', $invoice->invoice_number)
@section('content')
<div class="max-w-3xl mx-auto">
<div class="cat-card p-4 lg:p-8" x-data="invoiceEditForm()">
<form action="{{ route('invoices.update', $invoice) }}" method="POST">
@csrf @method('PUT')
{{-- Header fields --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-5">
<div class="sm:col-span-2">
<label class="block text-sm font-semibold text-gray-700 mb-1">Invoice Title</label>
<input type="text" name="title" value="{{ old('title', $invoice->title) }}" class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
<select name="client_id" class="input-soft">
<option value="">— No client —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}" {{ old('client_id', $invoice->client_id) == $client->id ? 'selected' : '' }}>{{ $client->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Status</label>
<select name="status" class="input-soft">
@foreach($statuses as $s)
<option value="{{ $s->value }}" {{ old('status', $invoice->status->value) === $s->value ? 'selected' : '' }}>{{ $s->label() }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Issue Date</label>
<input type="date" name="issue_date" value="{{ old('issue_date', $invoice->issue_date->toDateString()) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Due Date</label>
<input type="date" name="due_date" value="{{ old('due_date', $invoice->due_date->toDateString()) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Paid Date</label>
<input type="date" name="paid_date" value="{{ old('paid_date', $invoice->paid_date?->toDateString()) }}" class="input-soft">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Tax Rate (%)</label>
<input type="number" name="tax_rate" x-model="taxRate" @input="recalc()"
value="{{ old('tax_rate', $invoice->tax_rate) }}" class="input-soft" step="0.01">
</div>
</div>
{{-- Line Items --}}
<div class="mb-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-bold text-gray-700">Line Items</h3>
<button type="button" @click="addItem()" class="btn-sakura text-xs py-1.5 px-4">+ Add Item</button>
</div>
<div class="space-y-2">
<template x-for="(item, index) in items" :key="index">
<div class="bg-pink-50 rounded-2xl p-3">
<input type="text" :name="`items[${index}][description]`"
x-model="item.description"
class="input-soft text-sm mb-2 bg-white"
placeholder="Service description" required>
<div class="flex gap-2 items-center">
<div class="w-16 shrink-0">
<label class="text-xs text-gray-400 block mb-0.5">Qty</label>
<input type="number" :name="`items[${index}][quantity]`"
x-model.number="item.quantity" @input="recalc()"
class="input-soft text-sm text-center bg-white px-2" min="1">
</div>
<div class="flex-1">
<label class="text-xs text-gray-400 block mb-0.5">Unit Price</label>
<input type="number" :name="`items[${index}][unit_price]`"
x-model.number="item.unit_price" @input="recalc()"
class="input-soft text-sm bg-white" min="0" step="1000">
</div>
<div class="w-24 shrink-0 text-right">
<label class="text-xs text-gray-400 block mb-0.5">Total</label>
<div class="text-sm font-bold text-gray-700 pt-2">
Rp <span x-text="formatNum(item.quantity * item.unit_price)"></span>
</div>
</div>
<button type="button" @click="removeItem(index)"
class="text-red-400 hover:text-red-600 text-lg shrink-0 pt-4">✕</button>
</div>
</div>
</template>
</div>
</div>
{{-- Totals --}}
<div class="bg-pink-50 rounded-2xl p-4 mb-5">
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Subtotal</span>
<span class="font-semibold">Rp <span x-text="formatNum(subtotal)"></span></span>
</div>
<div class="flex justify-between text-sm py-1">
<span class="text-gray-500">Tax (<span x-text="taxRate"></span>%)</span>
<span class="font-semibold">Rp <span x-text="formatNum(taxAmount)"></span></span>
</div>
<div class="flex justify-between text-base font-bold py-2 mt-1 border-t border-pink-200">
<span class="text-gray-700">Total</span>
<span class="text-pink-600">Rp <span x-text="formatNum(total)"></span></span>
</div>
</div>
{{-- Bank Info --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5">
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Bank Name</label>
<input type="text" name="bank_name" value="{{ old('bank_name', $invoice->bank_name) }}"
class="input-soft text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Number</label>
<input type="text" name="bank_account" value="{{ old('bank_account', $invoice->bank_account) }}"
class="input-soft text-sm">
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 mb-1">Account Holder</label>
<input type="text" name="bank_holder" value="{{ old('bank_holder', $invoice->bank_holder) }}"
class="input-soft text-sm">
</div>
</div>
<div class="mb-5">
<label class="block text-sm font-semibold text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="2" class="input-soft resize-none">{{ old('notes', $invoice->notes) }}</textarea>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button type="submit" class="btn-sakura flex-1 py-3">💾 Save Changes</button>
<a href="{{ route('invoices.show', $invoice) }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@push('scripts')
<script>
function invoiceEditForm() {
return {
items: @json($invoice->items->map(fn($i) => ['description' => $i->description, 'quantity' => $i->quantity, 'unit_price' => (float)$i->unit_price])),
taxRate: {{ $invoice->tax_rate }},
get subtotal() { return this.items.reduce((s, i) => s + (i.quantity * i.unit_price), 0); },
get taxAmount() { return Math.round(this.subtotal * this.taxRate / 100); },
get total() { return this.subtotal + this.taxAmount; },
addItem() { this.items.push({ description: '', quantity: 1, unit_price: 0 }); },
removeItem(i) { if (this.items.length > 1) this.items.splice(i, 1); },
recalc() {},
formatNum(n) { return Number(n).toLocaleString('id-ID'); },
};
}
</script>
@endpush
@endsection

View File

@@ -0,0 +1,103 @@
@extends('layouts.app')
@section('title', 'Invoices')
@section('page-title', 'Invoices 🧾')
@section('page-subtitle', 'Manage your brand deal payments')
@section('content')
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<div class="flex gap-2 flex-wrap">
@foreach(['all','draft','sent','paid','overdue'] as $s)
<a href="{{ request()->fullUrlWithQuery(['status' => $s]) }}"
class="px-3 py-1.5 rounded-full text-xs font-semibold transition-colors
{{ request('status', 'all') === $s
? 'text-white'
: 'bg-white text-gray-500 hover:bg-pink-50 border border-pink-100' }}"
style="{{ request('status', 'all') === $s ? 'background:#FFB7C5' : '' }}">
{{ ucfirst($s) }}
</a>
@endforeach
</div>
<a href="{{ route('invoices.create') }}" class="btn-sakura text-sm shrink-0">+ New Invoice</a>
</div>
{{-- Mobile card list --}}
<div class="flex flex-col gap-3 sm:hidden">
@forelse($invoices as $invoice)
<div class="cat-card p-4">
<div class="flex items-start justify-between gap-2 mb-2">
<div>
<div class="font-mono text-sm font-bold text-gray-700">{{ $invoice->invoice_number }}</div>
<div class="text-xs text-gray-400">{{ $invoice->client?->name ?? '—' }}</div>
</div>
<span class="badge {{ $invoice->status->badgeClass() }} shrink-0">{{ $invoice->status->label() }}</span>
</div>
<div class="text-sm text-gray-700 font-semibold mb-1">{{ $invoice->title }}</div>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>Due {{ $invoice->due_date->format('d M Y') }}</span>
<span class="font-bold text-gray-800">{{ $invoice->formatted_total }}</span>
</div>
<div class="flex gap-2 mt-3 pt-3 border-t border-pink-50">
<a href="{{ route('invoices.show', $invoice) }}" class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50">View</a>
<a href="{{ route('invoices.pdf', $invoice) }}" target="_blank" class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-pink-600 hover:bg-pink-50">PDF</a>
<a href="{{ route('invoices.edit', $invoice) }}" class="flex-1 text-center text-xs py-1.5 px-3 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50">Edit</a>
</div>
</div>
@empty
<div class="text-center py-16 text-gray-400">
<span class="text-5xl block mb-2">🧾</span>
<p>No invoices yet. <a href="{{ route('invoices.create') }}" class="text-pink-500 hover:underline">Create one!</a></p>
</div>
@endforelse
</div>
{{-- Desktop table --}}
<div class="cat-card overflow-hidden hidden sm:block">
<div class="overflow-x-auto">
<table class="table w-full">
<thead class="bg-pink-50">
<tr>
<th class="px-5 py-4">Invoice #</th>
<th class="px-5 py-4">Client</th>
<th class="px-5 py-4">Title</th>
<th class="px-5 py-4">Total</th>
<th class="px-5 py-4">Due Date</th>
<th class="px-5 py-4">Status</th>
<th class="px-5 py-4">Actions</th>
</tr>
</thead>
<tbody>
@forelse($invoices as $invoice)
<tr class="border-b border-pink-50 hover:bg-pink-50/50 transition-colors">
<td class="px-5 py-4 font-mono text-sm font-semibold text-gray-700">{{ $invoice->invoice_number }}</td>
<td class="px-5 py-4 text-sm text-gray-600">{{ $invoice->client?->name ?? '—' }}</td>
<td class="px-5 py-4 text-sm text-gray-700 max-w-xs truncate">{{ $invoice->title }}</td>
<td class="px-5 py-4 font-bold text-gray-800 text-sm">{{ $invoice->formatted_total }}</td>
<td class="px-5 py-4 text-sm text-gray-500">
{{ $invoice->due_date->format('d M Y') }}
@if($invoice->due_date->isPast() && $invoice->status->value !== 'paid')
<span class="text-red-400 text-xs block">Overdue!</span>
@endif
</td>
<td class="px-5 py-4"><span class="badge {{ $invoice->status->badgeClass() }}">{{ $invoice->status->label() }}</span></td>
<td class="px-5 py-4">
<div class="flex items-center gap-2">
<a href="{{ route('invoices.show', $invoice) }}" class="text-xs text-pink-500 hover:underline">View</a>
<a href="{{ route('invoices.pdf', $invoice) }}" class="text-xs text-pink-500 hover:underline" target="_blank">PDF</a>
<a href="{{ route('invoices.edit', $invoice) }}" class="text-xs text-gray-500 hover:underline">Edit</a>
</div>
</td>
</tr>
@empty
<tr><td colspan="7" class="text-center py-16 text-gray-400">
<span class="text-4xl block mb-2">🧾</span>
No invoices yet.
</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-4">{{ $invoices->links() }}</div>
@endsection

View File

@@ -0,0 +1,119 @@
<!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: #374151; font-size: 13px; padding: 40px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; }
.logo { font-size: 22px; font-weight: 900; color: #FFB7C5; }
.logo-sub { font-size: 11px; color: #9CA3AF; margin-top: 2px; }
.inv-number { font-size: 22px; font-weight: 700; color: #1F2937; text-align: right; }
.badge { display: inline-block; padding: 3px 12px; border-radius: 20px; font-size: 11px; font-weight: 700; background: #FDE68A; color: #92400E; }
.section-title { font-size: 10px; font-weight: 700; color: #9CA3AF; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
.billing-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-bottom: 32px; }
.billing-name { font-weight: 700; font-size: 15px; color: #111827; }
.billing-detail { font-size: 12px; color: #6B7280; margin-top: 2px; }
.detail-table { width: 100%; font-size: 12px; }
.detail-table td { padding: 3px 0; }
.detail-table td:first-child { color: #9CA3AF; }
.detail-table td:last-child { font-weight: 600; text-align: right; }
table.items { width: 100%; border-collapse: collapse; margin-bottom: 24px; }
table.items thead { background: #FFF0F5; }
table.items th { padding: 10px 12px; text-align: left; font-size: 10px; font-weight: 700; color: #9CA3AF; text-transform: uppercase; }
table.items th:last-child, table.items td:last-child { text-align: right; }
table.items th:nth-child(2), table.items td:nth-child(2) { text-align: center; }
table.items td { padding: 10px 12px; border-bottom: 1px solid #FDE8F0; font-size: 12px; }
.totals { display: flex; justify-content: flex-end; margin-bottom: 24px; }
.totals-box { width: 260px; }
.totals-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; }
.totals-row.total { border-top: 2px solid #FFB7C5; margin-top: 6px; padding-top: 8px; font-size: 15px; font-weight: 800; color: #FFB7C5; }
.bank-box { background: #FFF5F7; border-radius: 12px; padding: 14px 16px; margin-bottom: 16px; }
.bank-title { font-weight: 700; font-size: 12px; color: #374151; margin-bottom: 4px; }
.bank-detail { font-size: 12px; color: #6B7280; }
.notes { font-size: 11px; color: #9CA3AF; font-style: italic; }
.footer { margin-top: 40px; text-align: center; font-size: 11px; color: #D1D5DB; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="logo">🐱 KOL Manager</div>
<div class="logo-sub">Personal Talent Assistant</div>
</div>
<div style="text-align:right">
<div class="inv-number">{{ $invoice->invoice_number }}</div>
<div style="margin-top:4px"><span class="badge">{{ $invoice->status->label() }}</span></div>
</div>
</div>
<div class="billing-grid">
<div>
<div class="section-title">Bill To</div>
@if($invoice->client)
<div class="billing-name">{{ $invoice->client->name }}</div>
@if($invoice->client->company) <div class="billing-detail">{{ $invoice->client->company }}</div> @endif
@if($invoice->client->email) <div class="billing-detail">{{ $invoice->client->email }}</div> @endif
@if($invoice->client->full_address) <div class="billing-detail">{{ $invoice->client->full_address }}</div> @endif
@else
<div class="billing-detail">—</div>
@endif
</div>
<div style="text-align:right">
<div class="section-title">Invoice Details</div>
<table class="detail-table">
<tr><td>Issue Date</td><td>{{ $invoice->issue_date->format('d M Y') }}</td></tr>
<tr><td>Due Date</td><td>{{ $invoice->due_date->format('d M Y') }}</td></tr>
@if($invoice->paid_date)
<tr><td>Paid Date</td><td>{{ $invoice->paid_date->format('d M Y') }}</td></tr>
@endif
<tr><td>Currency</td><td>{{ $invoice->currency }}</td></tr>
</table>
</div>
</div>
<table class="items">
<thead>
<tr>
<th>Description</th>
<th>Qty</th>
<th style="text-align:right">Unit Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
@foreach($invoice->items as $item)
<tr>
<td>{{ $item->description }}</td>
<td style="text-align:center">{{ $item->quantity }}</td>
<td style="text-align:right">Rp {{ number_format($item->unit_price, 0, ',', '.') }}</td>
<td style="text-align:right; font-weight:600">Rp {{ number_format($item->total, 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="totals">
<div class="totals-box">
<div class="totals-row"><span>Subtotal</span><span>Rp {{ number_format($invoice->subtotal, 0, ',', '.') }}</span></div>
<div class="totals-row"><span>Tax ({{ $invoice->tax_rate }}%)</span><span>Rp {{ number_format($invoice->tax_amount, 0, ',', '.') }}</span></div>
<div class="totals-row total"><span>Total</span><span>{{ $invoice->formatted_total }}</span></div>
</div>
</div>
@if($invoice->bank_name)
<div class="bank-box">
<div class="bank-title">💳 Payment Information</div>
<div class="bank-detail">{{ $invoice->bank_name }} · {{ $invoice->bank_account }} · a/n {{ $invoice->bank_holder }}</div>
</div>
@endif
@if($invoice->notes)
<div class="notes">{{ $invoice->notes }}</div>
@endif
<div class="footer">Generated by KOL Manager · Thank you for the collaboration! 🐱</div>
</body>
</html>

View File

@@ -0,0 +1,104 @@
@extends('layouts.app')
@section('title', $invoice->invoice_number)
@section('page-title', 'Invoice ' . $invoice->invoice_number)
@section('page-subtitle', $invoice->title)
@section('content')
<div class="max-w-3xl mx-auto">
<div class="flex gap-3 mb-6">
<a href="{{ route('invoices.edit', $invoice) }}" class="btn-sakura">Edit</a>
<a href="{{ route('invoices.pdf', $invoice) }}" target="_blank" class="btn btn-outline rounded-full text-pink-500 border-pink-300">📥 Download PDF</a>
<form action="{{ route('invoices.destroy', $invoice) }}" method="POST" onsubmit="return confirm('Delete this invoice?')">
@csrf @method('DELETE')
<button class="btn btn-ghost rounded-full text-error">Delete</button>
</form>
</div>
<div class="cat-card p-8">
{{-- Invoice Header --}}
<div class="flex justify-between items-start mb-8">
<div>
<div class="text-2xl font-extrabold text-pink-500 flex items-center gap-2">🐱 KOL Manager</div>
<div class="text-sm text-gray-400 mt-1">Personal Talent Assistant</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-gray-800">{{ $invoice->invoice_number }}</div>
<span class="badge {{ $invoice->status->badgeClass() }} mt-1">{{ $invoice->status->label() }}</span>
</div>
</div>
{{-- Billing Info --}}
<div class="grid grid-cols-2 gap-8 mb-8">
<div>
<h4 class="text-xs font-bold text-gray-400 uppercase mb-2">Bill To</h4>
@if($invoice->client)
<div class="font-semibold text-gray-800">{{ $invoice->client->name }}</div>
@if($invoice->client->company) <div class="text-sm text-gray-500">{{ $invoice->client->company }}</div> @endif
@if($invoice->client->email) <div class="text-sm text-gray-500">{{ $invoice->client->email }}</div> @endif
@if($invoice->client->full_address) <div class="text-sm text-gray-400">{{ $invoice->client->full_address }}</div> @endif
@else
<div class="text-gray-500 text-sm">No client</div>
@endif
</div>
<div class="text-right">
<div class="text-xs font-bold text-gray-400 uppercase mb-2">Invoice Details</div>
<div class="text-sm space-y-1">
<div><span class="text-gray-400">Issue:</span> {{ $invoice->issue_date->format('d M Y') }}</div>
<div><span class="text-gray-400">Due:</span> <span class="{{ $invoice->due_date->isPast() && $invoice->status->value !== 'paid' ? 'text-red-500 font-bold' : '' }}">{{ $invoice->due_date->format('d M Y') }}</span></div>
@if($invoice->paid_date) <div><span class="text-gray-400">Paid:</span> {{ $invoice->paid_date->format('d M Y') }}</div> @endif
</div>
</div>
</div>
{{-- Line Items --}}
<table class="w-full mb-6">
<thead class="bg-pink-50">
<tr class="text-xs text-gray-500 uppercase">
<th class="text-left py-3 px-4 rounded-l-2xl">Description</th>
<th class="text-center py-3 px-4">Qty</th>
<th class="text-right py-3 px-4">Unit Price</th>
<th class="text-right py-3 px-4 rounded-r-2xl">Total</th>
</tr>
</thead>
<tbody>
@foreach($invoice->items as $item)
<tr class="border-b border-pink-50">
<td class="py-3 px-4 text-sm text-gray-700">{{ $item->description }}</td>
<td class="py-3 px-4 text-sm text-center text-gray-600">{{ $item->quantity }}</td>
<td class="py-3 px-4 text-sm text-right text-gray-600">Rp {{ number_format($item->unit_price, 0, ',', '.') }}</td>
<td class="py-3 px-4 text-sm text-right font-semibold text-gray-800">Rp {{ number_format($item->total, 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
{{-- Totals --}}
<div class="flex justify-end mb-8">
<div class="w-64 space-y-2 text-sm">
<div class="flex justify-between text-gray-500">
<span>Subtotal</span><span class="font-medium">Rp {{ number_format($invoice->subtotal, 0, ',', '.') }}</span>
</div>
<div class="flex justify-between text-gray-500">
<span>Tax ({{ $invoice->tax_rate }}%)</span><span class="font-medium">Rp {{ number_format($invoice->tax_amount, 0, ',', '.') }}</span>
</div>
<div class="flex justify-between text-base font-bold text-pink-600 pt-2 border-t border-pink-200">
<span>Total</span><span>{{ $invoice->formatted_total }}</span>
</div>
</div>
</div>
{{-- Bank Info --}}
@if($invoice->bank_name)
<div class="bg-pink-50 rounded-2xl p-4 text-sm">
<div class="font-semibold text-gray-700 mb-2">💳 Payment Info</div>
<div class="text-gray-600">{{ $invoice->bank_name }} · {{ $invoice->bank_account }} · {{ $invoice->bank_holder }}</div>
</div>
@endif
@if($invoice->notes)
<div class="mt-4 text-sm text-gray-500 italic">{{ $invoice->notes }}</div>
@endif
</div>
</div>
@endsection

View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name') }} — @yield('title', 'Dashboard')</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-cream font-sans antialiased" x-data="{ sidebarOpen: false }">
{{-- ── Backdrop (mobile only) ─────────────────────────────────── --}}
<div
x-show="sidebarOpen"
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="sidebarOpen = false"
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
style="display:none"
></div>
{{-- ── Sidebar ─────────────────────────────────────────────────── --}}
<aside
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
class="fixed inset-y-0 left-0 z-40 w-64 bg-white border-r border-pink-100 shadow-lg
flex flex-col py-6 px-4
transition-transform duration-300 ease-in-out
lg:translate-x-0"
>
{{-- Logo / Close trigger --}}
<div class="flex items-center justify-between gap-3 mb-8 px-2">
<button
@click="sidebarOpen = false"
class="flex items-center gap-3 group focus:outline-none"
aria-label="Close menu"
>
<span class="text-3xl transition-transform duration-200 group-hover:scale-110 group-active:scale-95 select-none">🐱</span>
<div class="text-left">
<div class="text-lg font-extrabold text-pink-500 leading-tight">KOL Manager</div>
<div class="text-xs text-gray-400">Your Purr-fect Assistant</div>
</div>
</button>
{{-- X button — mobile only --}}
<button @click="sidebarOpen = false" class="lg:hidden text-gray-400 hover:text-pink-500 text-xl leading-none" aria-label="Close">✕</button>
</div>
{{-- Nav --}}
<nav class="flex flex-col gap-1 flex-1 overflow-y-auto">
<a href="{{ route('dashboard') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('dashboard') ? 'active' : '' }}">
<span class="text-lg shrink-0">🏠</span> Dashboard
</a>
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mt-4 mb-1 px-2">Manage</div>
<a href="{{ route('schedule.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('schedule.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">📅</span>
<span class="flex-1">Schedule</span>
@if($alertCount > 0)
<span class="badge-sakura shrink-0">{{ $alertCount }}</span>
@endif
</a>
<a href="{{ route('clients.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('clients.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">👥</span> Clients / CRM
</a>
<a href="{{ route('social-hub.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('social-hub.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">📱</span> Social Hub
</a>
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mt-4 mb-1 px-2">Finance</div>
<a href="{{ route('invoices.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('invoices.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">🧾</span> Invoices
</a>
<a href="{{ route('rate-cards.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('rate-cards.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">💰</span> Rate Cards
</a>
<div class="text-xs font-bold text-gray-400 uppercase tracking-wider mt-4 mb-1 px-2">Content</div>
<a href="{{ route('content.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('content.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">🎬</span> Content Board
</a>
<a href="{{ route('files.index') }}"
@click="sidebarOpen = false"
class="nav-item {{ request()->routeIs('files.*') ? 'active' : '' }}">
<span class="text-lg shrink-0">📁</span> Files
</a>
</nav>
{{-- Meow Toggle --}}
<div class="mt-4 px-2 border-t border-pink-50 pt-4">
<button onclick="window.KolManager.toggleMeow()"
class="flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors w-full">
<span class="text-base">🔔</span>
<span>Meow Sounds</span>
</button>
<div class="text-xs text-gray-400 mt-1">Toggle notification sounds</div>
</div>
</aside>
{{-- ── Main wrapper ─────────────────────────────────────────────── --}}
<div class="min-h-screen flex flex-col lg:ml-64">
<div class="flex-1 flex flex-col min-w-0">
{{-- ── Mobile / Desktop Topbar ─────────────────────────── --}}
<header class="sticky top-0 z-20 bg-white border-b border-pink-100 px-4 lg:px-8 py-3 lg:py-4 flex items-center gap-3 shadow-sm">
{{-- Cat icon — hamburger for mobile --}}
<button
@click="sidebarOpen = true"
class="lg:hidden text-2xl transition-transform duration-150 active:scale-90 focus:outline-none select-none"
aria-label="Open menu"
>🐱</button>
<div class="flex-1 min-w-0">
<h1 class="text-base lg:text-xl font-bold text-gray-800 truncate">@yield('page-title', 'Dashboard')</h1>
<p class="text-xs lg:text-sm text-gray-400 hidden sm:block">@yield('page-subtitle', today()->format('l, d F Y'))</p>
</div>
<div class="flex items-center gap-2 shrink-0">
@if($alertCount > 0)
<a href="{{ route('schedule.index') }}"
class="hidden sm:flex items-center gap-1.5 bg-pink-50 text-pink-600 text-xs px-3 py-1.5 rounded-full border border-pink-200">
<span>🔔</span>
<span class="font-semibold">{{ $alertCount }} alert{{ $alertCount > 1 ? 's' : '' }}</span>
</a>
{{-- Mobile: just the badge --}}
<a href="{{ route('schedule.index') }}" class="sm:hidden badge-sakura flex items-center gap-1">
🔔 {{ $alertCount }}
</a>
@endif
<div class="w-8 h-8 lg:w-9 lg:h-9 rounded-full flex items-center justify-center text-white font-bold text-xs lg:text-sm shadow-sm"
style="background: linear-gradient(135deg, #FFB7C5, #E8D5F5);">
KOL
</div>
</div>
</header>
{{-- ── Flash Messages ───────────────────────────────────── --}}
@if(session('success'))
<div id="flash-message"
class="mx-4 lg:mx-8 mt-4 flex items-center gap-3 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-2xl shadow-sm">
<span class="text-lg">✅</span>
<span class="text-sm font-medium">{{ session('success') }}</span>
</div>
@endif
@if(session('error'))
<div id="flash-message"
class="mx-4 lg:mx-8 mt-4 flex items-center gap-3 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-2xl shadow-sm">
<span class="text-lg">❌</span>
<span class="text-sm font-medium">{{ session('error') }}</span>
</div>
@endif
{{-- ── Page Content ─────────────────────────────────────── --}}
<main class="flex-1 p-4 lg:p-8 overflow-x-hidden">
@yield('content')
</main>
</div>
</div>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,76 @@
@extends('layouts.app')
@section('title', 'Add Rate Card')
@section('page-title', 'Add Service 💰')
@section('content')
<div class="max-w-lg mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('rate-cards.store') }}" method="POST" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Service Name <span class="text-pink-400">*</span></label>
<input type="text" name="service_name" value="{{ old('service_name') }}" class="input-soft" required
placeholder="e.g. Instagram Reel (Paid Partnership)">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Description</label>
<textarea name="description" rows="2" class="input-soft resize-none"
placeholder="Short description of what's included...">{{ old('description') }}</textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Platform</label>
<select name="platform" class="input-soft">
<option value="general">⭐ General</option>
<option value="instagram">📸 Instagram</option>
<option value="tiktok">🎵 TikTok</option>
<option value="youtube">▶️ YouTube</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Content Type</label>
<input type="text" name="content_type" value="{{ old('content_type') }}" class="input-soft"
placeholder="Reel, Story, Feed...">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Price <span class="text-pink-400">*</span></label>
<input type="number" name="price" value="{{ old('price') }}" class="input-soft"
min="0" step="100000" required placeholder="0">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Currency</label>
<select name="currency" class="input-soft">
<option value="IDR" selected>IDR</option>
<option value="USD">USD</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Price Unit</label>
<select name="price_unit" class="input-soft">
<option value="per_content">Per Content</option>
<option value="per_month">Per Month</option>
<option value="per_campaign">Per Campaign</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Sort Order</label>
<input type="number" name="sort_order" value="{{ old('sort_order', 0) }}" class="input-soft" min="0">
</div>
</div>
<label class="flex items-center gap-3 cursor-pointer py-1">
<input type="checkbox" name="is_negotiable" value="1" {{ old('is_negotiable') ? 'checked' : '' }}
class="w-4 h-4 accent-yellow-400">
<span class="text-sm text-gray-600">Price is negotiable</span>
</label>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">💰 Add Service</button>
<a href="{{ route('rate-cards.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,88 @@
@extends('layouts.app')
@section('title', 'Edit Rate Card')
@section('page-title', 'Edit Service 💰')
@section('content')
<div class="max-w-lg mx-auto">
<div class="cat-card p-4 lg:p-8">
<form action="{{ route('rate-cards.update', $rateCard) }}" method="POST" class="space-y-4">
@csrf @method('PUT')
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Service Name</label>
<input type="text" name="service_name" value="{{ old('service_name', $rateCard->service_name) }}"
class="input-soft" required>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Description</label>
<textarea name="description" rows="2" class="input-soft resize-none">{{ old('description', $rateCard->description) }}</textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Platform</label>
<select name="platform" class="input-soft">
@foreach(['general','instagram','tiktok','youtube'] as $p)
<option value="{{ $p }}" {{ old('platform', $rateCard->platform) === $p ? 'selected' : '' }}>
{{ ucfirst($p) }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Content Type</label>
<input type="text" name="content_type" value="{{ old('content_type', $rateCard->content_type) }}"
class="input-soft" placeholder="Reel, Story...">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Price</label>
<input type="number" name="price" value="{{ old('price', $rateCard->price) }}"
class="input-soft" min="0" step="100000">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Currency</label>
<select name="currency" class="input-soft">
<option value="IDR" {{ old('currency', $rateCard->currency) === 'IDR' ? 'selected' : '' }}>IDR</option>
<option value="USD" {{ old('currency', $rateCard->currency) === 'USD' ? 'selected' : '' }}>USD</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Price Unit</label>
<select name="price_unit" class="input-soft">
@foreach(['per_content','per_month','per_campaign'] as $unit)
<option value="{{ $unit }}" {{ old('price_unit', $rateCard->price_unit) === $unit ? 'selected' : '' }}>
{{ str_replace('_', ' ', ucfirst($unit)) }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">Sort Order</label>
<input type="number" name="sort_order" value="{{ old('sort_order', $rateCard->sort_order) }}"
class="input-soft" min="0">
</div>
</div>
<div class="flex flex-col gap-2 pt-1">
<label class="flex items-center gap-3 cursor-pointer py-1">
<input type="checkbox" name="is_negotiable" value="1"
{{ old('is_negotiable', $rateCard->is_negotiable) ? 'checked' : '' }}
class="w-4 h-4 accent-yellow-400">
<span class="text-sm text-gray-600">Price is negotiable</span>
</label>
<label class="flex items-center gap-3 cursor-pointer py-1">
<input type="checkbox" name="is_active" value="1"
{{ old('is_active', $rateCard->is_active) ? 'checked' : '' }}
class="w-4 h-4 accent-green-400">
<span class="text-sm text-gray-600">Active (show on public page)</span>
</label>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-1">
<button type="submit" class="btn-sakura flex-1 py-3">💾 Save Changes</button>
<a href="{{ route('rate-cards.index') }}"
class="flex-1 py-3 text-center rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-semibold text-sm">
Cancel
</a>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,82 @@
@extends('layouts.app')
@section('title', 'Rate Cards')
@section('page-title', 'Rate Cards 💰')
@section('page-subtitle', 'Manage your service pricing')
@section('content')
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<a href="{{ route('rate-cards.public') }}" target="_blank"
class="text-sm text-pink-500 hover:underline flex items-center gap-1">
🔗 View Public Page →
</a>
<a href="{{ route('rate-cards.create') }}" class="btn-sakura text-sm shrink-0">+ Add Service</a>
</div>
@php
$grouped = $rateCards->groupBy('platform');
$platformOrder = ['general', 'instagram', 'tiktok', 'youtube'];
@endphp
@foreach($platformOrder as $platform)
@if($grouped->has($platform))
<div class="mb-6">
<h2 class="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
<span class="text-lg">{{ $grouped[$platform]->first()->platform_icon }}</span>
{{ ucfirst($platform) }}
<span class="text-xs text-gray-400 font-normal">({{ $grouped[$platform]->count() }})</span>
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
@foreach($grouped[$platform] as $card)
<div class="cat-card p-4 {{ !$card->is_active ? 'opacity-50' : '' }}">
<div class="flex justify-between items-start gap-2 mb-2">
<div class="min-w-0">
<h3 class="font-bold text-gray-800 text-sm leading-tight">{{ $card->service_name }}</h3>
@if($card->content_type)
<span class="text-xs bg-pink-50 text-pink-600 px-2 py-0.5 rounded-full inline-block mt-1">{{ $card->content_type }}</span>
@endif
</div>
<span class="text-xs px-2 py-1 rounded-full shrink-0
{{ $card->is_active ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' }}">
{{ $card->is_active ? 'Active' : 'Hidden' }}
</span>
</div>
@if($card->description)
<p class="text-xs text-gray-500 mb-2 line-clamp-2">{{ $card->description }}</p>
@endif
<div class="text-xl font-extrabold text-pink-500 mb-0.5">{{ $card->formatted_price }}</div>
<div class="text-xs text-gray-400 mb-3">
{{ str_replace('_', ' ', $card->price_unit) }}
@if($card->is_negotiable) <span class="text-amber-500 ml-1">· Negotiable</span> @endif
</div>
<div class="flex gap-2 pt-3 border-t border-pink-50">
<a href="{{ route('rate-cards.edit', $card) }}"
class="flex-1 text-center text-xs py-2 rounded-full border border-pink-200 text-gray-600 hover:bg-pink-50 font-medium">
Edit
</a>
<form action="{{ route('rate-cards.destroy', $card) }}" method="POST"
onsubmit="return confirm('Delete this entry?')">
@csrf @method('DELETE')
<button class="text-xs py-2 px-4 rounded-full border border-red-100 text-red-400 hover:bg-red-50">
Delete
</button>
</form>
</div>
</div>
@endforeach
</div>
</div>
@endif
@endforeach
@if($rateCards->isEmpty())
<div class="text-center py-20 text-gray-400">
<span class="text-5xl block mb-3">💰</span>
<p class="text-sm">No rate cards yet.</p>
<a href="{{ route('rate-cards.create') }}" class="btn-sakura mt-4 inline-block">Add your first service!</a>
</div>
@endif
@endsection

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Card — KOL Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Nunito', sans-serif; background: #FFF8F0; color: #374151; padding: 40px 20px; }
.container { max-width: 800px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 48px; }
.logo { font-size: 48px; margin-bottom: 12px; }
.title { font-size: 32px; font-weight: 900; color: #FFB7C5; }
.subtitle { color: #9CA3AF; margin-top: 6px; font-size: 14px; }
.platform-section { margin-bottom: 32px; }
.platform-title { font-size: 18px; font-weight: 800; color: #374151; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
.card { background: white; border-radius: 24px; padding: 20px; border: 1px solid #FFE4EC; box-shadow: 0 2px 8px rgba(255,183,197,0.15); }
.service { font-weight: 700; color: #1F2937; margin-bottom: 6px; }
.type { display: inline-block; background: #FFF0F5; color: #FFB7C5; font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 20px; margin-bottom: 8px; }
.desc { font-size: 12px; color: #9CA3AF; margin-bottom: 12px; line-height: 1.5; }
.price { font-size: 22px; font-weight: 900; color: #FFB7C5; }
.price-meta { font-size: 11px; color: #D1D5DB; margin-top: 2px; }
.negotiable { font-size: 11px; color: #F59E0B; font-weight: 600; }
.footer { text-align: center; margin-top: 60px; color: #D1D5DB; font-size: 12px; }
@media print { body { background: white; } .card { break-inside: avoid; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">🐱</div>
<div class="title">Rate Card</div>
<div class="subtitle">Services & Pricing · {{ now()->format('Y') }}</div>
</div>
@forelse($rateCards as $platform => $cards)
<div class="platform-section">
<div class="platform-title">
<span>{{ $cards->first()->platform_icon }}</span>
{{ ucfirst($platform) }}
</div>
<div class="cards">
@foreach($cards as $card)
<div class="card">
@if($card->content_type)
<div class="type">{{ $card->content_type }}</div>
@endif
<div class="service">{{ $card->service_name }}</div>
@if($card->description)
<div class="desc">{{ $card->description }}</div>
@endif
<div class="price">{{ $card->formatted_price }}</div>
<div class="price-meta">{{ str_replace('_', ' ', $card->price_unit) }}</div>
@if($card->is_negotiable)
<div class="negotiable">✦ Negotiable</div>
@endif
</div>
@endforeach
</div>
</div>
@empty
<p style="text-align:center; color:#9CA3AF; padding: 60px 0;">No rate cards published yet.</p>
@endforelse
<div class="footer">
Generated by KOL Manager · Prices may vary. Contact for custom packages. 🐾
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,252 @@
@extends('layouts.app')
@section('title', 'Schedule')
@section('page-title', 'Schedule & Calendar 📅')
@section('page-subtitle', 'Tap a date or event to manage it')
@section('content')
{{-- ── Add Event Form (always accessible, collapsible) ─────── --}}
<div class="cat-card p-4 mb-4" x-data="{ open: false }">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-gray-700 flex items-center gap-2">➕ New Event</h3>
<button class="btn-sakura text-xs px-4 py-1.5" @click="open = !open"
x-text="open ? '✕ Close' : '+ Add Event'"></button>
</div>
<form x-show="open" x-transition action="{{ route('schedule.store') }}" method="POST"
class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3"
id="new-event-form">
@csrf
{{-- Hidden combined datetime fields --}}
<input type="hidden" name="start_time" id="start_time_hidden">
<input type="hidden" name="end_time" id="end_time_hidden">
<div class="sm:col-span-2">
<label class="text-xs font-semibold text-gray-600">Title <span class="text-pink-400">*</span></label>
<input type="text" name="title" class="input-soft mt-1" placeholder="Event title" required>
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Type</label>
<select name="event_type" class="input-soft mt-1">
<option value="task">📋 Task</option>
<option value="meeting">🤝 Meeting</option>
<option value="deadline">⏰ Deadline</option>
<option value="posting">📸 Posting</option>
</select>
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Client</label>
<select name="client_id" class="input-soft mt-1">
<option value="">— None —</option>
@foreach($clients as $client)
<option value="{{ $client->id }}">{{ $client->name }}</option>
@endforeach
</select>
</div>
{{-- Start: date + time side by side --}}
<div>
<label class="text-xs font-semibold text-gray-600">Start Date <span class="text-pink-400">*</span></label>
<input type="date" id="start_date" class="input-soft mt-1" required>
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Start Time</label>
<input type="time" id="start_time_input" class="input-soft mt-1" value="09:00">
</div>
{{-- End: date + time side by side --}}
<div>
<label class="text-xs font-semibold text-gray-600">End Date</label>
<input type="date" id="end_date" class="input-soft mt-1">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">End Time</label>
<input type="time" id="end_time_input" class="input-soft mt-1" value="18:00">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Color</label>
<div class="flex gap-2 mt-1 flex-wrap">
@foreach(['#FFB7C5','#B5EAD7','#FDFD96','#E8D5F5','#FFD5B8','#AEC6CF'] as $hex)
<label class="cursor-pointer">
<input type="radio" name="color" value="{{ $hex }}" class="sr-only peer" {{ $loop->first ? 'checked' : '' }}>
<span class="w-7 h-7 rounded-full block ring-2 ring-transparent peer-checked:ring-gray-500 peer-checked:ring-offset-1 transition-all"
style="background:{{ $hex }};"></span>
</label>
@endforeach
</div>
</div>
<div class="flex items-center gap-2 mt-1">
<input type="checkbox" name="is_priority" value="1" id="priority" class="w-4 h-4 accent-pink-400">
<label for="priority" class="text-xs text-gray-600 cursor-pointer">⭐ Priority</label>
</div>
<div class="sm:col-span-2">
<label class="text-xs font-semibold text-gray-600">Notes</label>
<textarea name="notes" rows="2" class="input-soft mt-1 resize-none text-sm"></textarea>
</div>
<div class="sm:col-span-2">
<button type="submit" class="btn-sakura w-full">🐾 Schedule It!</button>
</div>
</form>
</div>
{{-- ── Upcoming Alert Strip ─────────────────────────────────── --}}
@php
$upcomingList = $events->filter(fn($e) => $e->start_time->isFuture() && $e->start_time->diffInHours(now()) <= 24);
@endphp
@if($upcomingList->isNotEmpty())
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 -mx-4 px-4 lg:mx-0 lg:px-0">
@foreach($upcomingList->take(6) as $event)
<div class="shrink-0 flex items-center gap-2 bg-white rounded-2xl border border-pink-100 px-3 py-2 shadow-sm">
<div class="w-1.5 h-8 rounded-full shrink-0" style="background:{{ $event->color }}"></div>
<div>
<div class="text-xs font-semibold text-gray-700 whitespace-nowrap">{{ $event->title }}</div>
<div class="text-xs text-gray-400">{{ $event->start_time->format('H:i') }}</div>
</div>
</div>
@endforeach
</div>
@endif
{{-- ── Calendar ─────────────────────────────────────────────── --}}
<div class="cat-card p-3 lg:p-5">
<div id="calendar"></div>
</div>
{{-- ── Event Detail Modal ───────────────────────────────────── --}}
<div id="event-modal" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 hidden">
<div class="cat-card p-5 w-full sm:max-w-md rounded-b-none sm:rounded-3xl shadow-xl mx-0 sm:mx-4">
<div class="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4 sm:hidden"></div>
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-gray-800 text-base" id="modal-title">Event</h3>
<button onclick="document.getElementById('event-modal').classList.add('hidden')"
class="text-gray-400 hover:text-gray-600 text-xl leading-none">✕</button>
</div>
<div id="modal-body" class="text-sm text-gray-600 space-y-2 mb-4"></div>
<div id="modal-actions" class="flex gap-2"></div>
</div>
</div>
@push('scripts')
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.css">
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.11/index.global.min.js"></script>
<style>
/* ── FullCalendar mobile overrides ── */
.fc .fc-toolbar { flex-wrap: wrap; gap: 8px; }
.fc .fc-toolbar-title { font-size: 1rem; font-weight: 700; }
.fc .fc-button {
background: #FFB7C5 !important;
border-color: #FFB7C5 !important;
border-radius: 999px !important;
font-size: 0.75rem !important;
padding: 4px 12px !important;
box-shadow: none !important;
}
.fc .fc-button:hover { background: #FF8FAD !important; border-color: #FF8FAD !important; }
.fc .fc-button-active { background: #FF8FAD !important; border-color: #FF8FAD !important; }
.fc .fc-daygrid-event { border-radius: 8px !important; font-size: 0.7rem; }
.fc .fc-list-event:hover td { background: #FFF0F5 !important; }
.fc .fc-list-day-cushion { background: #FFF8F0 !important; }
@media (max-width: 639px) {
.fc .fc-toolbar { gap: 6px; }
.fc .fc-toolbar-title { font-size: 0.9rem; }
.fc .fc-button { padding: 3px 8px !important; font-size: 0.7rem !important; }
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
const isMobile = window.innerWidth < 640;
const calendar = new FullCalendar.Calendar(document.getElementById('calendar'), {
initialView: isMobile ? 'listWeek' : 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: isMobile ? 'listWeek,timeGridDay' : 'dayGridMonth,timeGridWeek,listWeek',
},
editable: !isMobile,
selectable: true,
events: @json($calendarEvents),
height: 'auto',
windowResize(view) {
const mob = window.innerWidth < 640;
if (mob && calendar.view.type !== 'listWeek' && calendar.view.type !== 'timeGridDay') {
calendar.changeView('listWeek');
}
},
eventDrop(info) {
fetch(`/schedule/${info.event.id}/dates`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
start_time: info.event.startStr,
end_time: info.event.endStr || null,
}),
}).then(r => r.ok ? window.KolManager.notify('Event rescheduled! 🐾') : info.revert());
},
select(info) {
// Pre-fill date from calendar click
const dateInput = document.getElementById('start_date');
if (dateInput) dateInput.value = info.startStr.substring(0, 10);
const endInput = document.getElementById('end_date');
if (endInput && info.endStr) endInput.value = info.endStr.substring(0, 10);
},
eventClick(info) {
const e = info.event, p = e.extendedProps;
document.getElementById('modal-title').textContent = e.title;
document.getElementById('modal-body').innerHTML = `
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Type</span><span>${p.type}</span></div>
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Status</span><span>${p.status}</span></div>
${p.client ? `<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Client</span><span>${p.client}</span></div>` : ''}
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Start</span><span>${e.start?.toLocaleString()}</span></div>
${e.end ? `<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">End</span><span>${e.end.toLocaleString()}</span></div>` : ''}
`;
document.getElementById('modal-actions').innerHTML = `
<a href="/schedule/${e.id}/edit" class="btn-sakura text-sm flex-1 text-center py-2">Edit</a>
<form method="POST" action="/schedule/${e.id}" onsubmit="return confirm('Delete?')" class="flex-1">
<input type="hidden" name="_method" value="DELETE">
<input type="hidden" name="_token" value="${document.querySelector('meta[name=csrf-token]').content}">
<button class="w-full py-2 rounded-full border border-red-200 text-red-400 text-sm hover:bg-red-50">Delete</button>
</form>
`;
document.getElementById('event-modal').classList.remove('hidden');
},
});
calendar.render();
// Close modal on backdrop tap
document.getElementById('event-modal').addEventListener('click', function(e) {
if (e.target === this) this.classList.add('hidden');
});
// Combine date + time inputs into hidden datetime fields before submit
document.getElementById('new-event-form').addEventListener('submit', function(e) {
const startDate = document.getElementById('start_date').value;
const startTime = document.getElementById('start_time_input').value || '00:00';
const endDate = document.getElementById('end_date').value;
const endTime = document.getElementById('end_time_input').value || '00:00';
if (!startDate) {
e.preventDefault();
alert('Please select a start date.');
return;
}
document.getElementById('start_time_hidden').value = startDate + 'T' + startTime;
if (endDate) {
document.getElementById('end_time_hidden').value = endDate + 'T' + endTime;
}
});
});
</script>
@endpush
@endsection

View File

@@ -0,0 +1,142 @@
@extends('layouts.app')
@section('title', 'Social Hub')
@section('page-title', 'Social Media Hub 📱')
@section('page-subtitle', 'Track your platform stats and posting frequency')
@section('content')
{{-- Platform Cards --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
@foreach($stats as $item)
@php $stat = $item['latest']; $platform = $item['platform']; @endphp
<div class="cat-card p-6">
<div class="flex items-center gap-3 mb-4">
<span class="text-3xl">{{ $stat?->platform_icon ?? '📱' }}</span>
<div>
<div class="font-bold text-gray-800 capitalize">{{ $platform }}</div>
<div class="text-xs text-gray-400">{{ $stat?->handle ?? 'Not connected' }}</div>
</div>
</div>
@if($stat)
<div class="grid grid-cols-2 gap-3">
<div class="bg-pink-50 rounded-2xl p-3 text-center">
<div class="text-xl font-extrabold text-pink-500">{{ $stat->formatted_followers }}</div>
<div class="text-xs text-gray-500">Followers</div>
</div>
<div class="bg-purple-50 rounded-2xl p-3 text-center">
<div class="text-xl font-extrabold text-purple-500">{{ $stat->engagement_rate }}%</div>
<div class="text-xs text-gray-500">Engagement</div>
</div>
<div class="bg-amber-50 rounded-2xl p-3 text-center">
<div class="text-xl font-extrabold text-amber-500">{{ number_format($stat->avg_views) }}</div>
<div class="text-xs text-gray-500">Avg Views</div>
</div>
<div class="bg-green-50 rounded-2xl p-3 text-center">
<div class="text-xl font-extrabold text-green-500">{{ number_format($stat->avg_likes) }}</div>
<div class="text-xs text-gray-500">Avg Likes</div>
</div>
</div>
<p class="text-xs text-gray-400 mt-3 text-right">Updated {{ $stat->recorded_date->format('d M Y') }}</p>
@else
<div class="text-center py-6 text-gray-400">
<p class="text-sm">No stats yet</p>
</div>
@endif
</div>
@endforeach
</div>
{{-- Update Stats --}}
<div class="cat-card p-6 mb-8" x-data="{ open: false, platform: 'instagram' }">
<div class="flex items-center justify-between">
<h2 class="font-bold text-gray-700 flex items-center gap-2">📊 Update Stats</h2>
<button class="btn-sakura text-sm" @click="open = !open">
<span x-text="open ? '✕ Close' : '+ Add Stats'"></span>
</button>
</div>
<form x-show="open" x-transition action="{{ route('social-hub.store-stats') }}" method="POST"
class="mt-5 grid grid-cols-2 lg:grid-cols-4 gap-4">
@csrf
<div class="col-span-2 lg:col-span-1">
<label class="text-xs font-semibold text-gray-600">Platform</label>
<select name="platform" x-model="platform" class="input-soft mt-1">
<option value="instagram">📸 Instagram</option>
<option value="tiktok">🎵 TikTok</option>
<option value="youtube">▶️ YouTube</option>
</select>
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Handle</label>
<input type="text" name="handle" class="input-soft mt-1 text-sm" placeholder="@yourhandle">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Followers</label>
<input type="number" name="followers" class="input-soft mt-1 text-sm" placeholder="0" min="0" required>
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Following</label>
<input type="number" name="following" class="input-soft mt-1 text-sm" placeholder="0" min="0">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Posts Count</label>
<input type="number" name="posts_count" class="input-soft mt-1 text-sm" placeholder="0" min="0">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Engagement Rate (%)</label>
<input type="number" name="engagement_rate" class="input-soft mt-1 text-sm" placeholder="0.00" step="0.01">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Avg Views</label>
<input type="number" name="avg_views" class="input-soft mt-1 text-sm" placeholder="0" min="0">
</div>
<div>
<label class="text-xs font-semibold text-gray-600">Avg Likes</label>
<input type="number" name="avg_likes" class="input-soft mt-1 text-sm" placeholder="0" min="0">
</div>
<div class="col-span-2 lg:col-span-4">
<button type="submit" class="btn-sakura text-sm px-6">📊 Save Stats</button>
</div>
</form>
</div>
{{-- Posting Frequency --}}
<div class="cat-card p-6">
<h2 class="font-bold text-gray-700 mb-4 flex items-center gap-2">📈 Posting Frequency</h2>
@if($postingFrequency->isNotEmpty())
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs text-gray-500">
<th>Platform</th>
<th>Month</th>
<th>Posts</th>
</tr>
</thead>
<tbody>
@foreach($postingFrequency as $row)
<tr class="hover">
<td class="capitalize">{{ $row->platform }}</td>
<td>{{ $row->month }}</td>
<td>
<div class="flex items-center gap-2">
<div class="w-24 h-2 rounded-full bg-pink-100">
<div class="h-2 rounded-full bg-sakura" style="width: {{ min(100, $row->count * 10) }}%;"></div>
</div>
<span class="font-bold text-gray-700">{{ $row->count }}</span>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-8 text-gray-400">
<span class="text-4xl block mb-2">📱</span>
<p class="text-sm">No posted content yet. Mark content as "Posted" to see frequency here.</p>
</div>
@endif
</div>
@endsection

File diff suppressed because one or more lines are too long

8
routes/console.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

62
routes/web.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\ClientController;
use App\Http\Controllers\ContentBriefController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EventController;
use App\Http\Controllers\InvoiceController;
use App\Http\Controllers\MediaFileController;
use App\Http\Controllers\RateCardController;
use App\Http\Controllers\SocialHubController;
use Illuminate\Support\Facades\Route;
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
// CRM – Clients
Route::resource('clients', ClientController::class);
// Schedule & Calendar
Route::prefix('schedule')->name('schedule.')->group(function (): void {
Route::get('/', [EventController::class, 'index'])->name('index');
Route::post('/', [EventController::class, 'store'])->name('store');
Route::put('/{event}', [EventController::class, 'update'])->name('update');
Route::patch('/{event}/dates', [EventController::class, 'updateDates'])->name('update-dates');
Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy');
Route::get('/api/events', [EventController::class, 'apiList'])->name('api-events');
});
// Social Media Hub
Route::prefix('social-hub')->name('social-hub.')->group(function (): void {
Route::get('/', [SocialHubController::class, 'index'])->name('index');
Route::post('/stats', [SocialHubController::class, 'storeStats'])->name('store-stats');
});
// Financial – Invoices
Route::resource('invoices', InvoiceController::class);
Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'pdf'])->name('invoices.pdf');
// Financial – Rate Cards
Route::get('/rate-cards/public', [RateCardController::class, 'public'])->name('rate-cards.public');
Route::resource('rate-cards', RateCardController::class)->except(['show']);
// Content Strategy – Kanban
Route::prefix('content')->name('content.')->group(function (): void {
Route::get('/', [ContentBriefController::class, 'index'])->name('index');
Route::get('/create', [ContentBriefController::class, 'create'])->name('create');
Route::post('/', [ContentBriefController::class, 'store'])->name('store');
Route::get('/{contentBrief}', [ContentBriefController::class, 'show'])->name('show');
Route::get('/{contentBrief}/edit', [ContentBriefController::class, 'edit'])->name('edit');
Route::put('/{contentBrief}', [ContentBriefController::class, 'update'])->name('update');
Route::delete('/{contentBrief}', [ContentBriefController::class, 'destroy'])->name('destroy');
Route::patch('/{contentBrief}/status', [ContentBriefController::class, 'updateStatus'])->name('update-status');
});
// File Management
Route::prefix('files')->name('files.')->group(function (): void {
Route::get('/', [MediaFileController::class, 'index'])->name('index');
Route::post('/', [MediaFileController::class, 'store'])->name('store');
Route::get('/{mediaFile}/serve', [MediaFileController::class, 'serve'])->name('serve');
Route::delete('/{mediaFile}', [MediaFileController::class, 'destroy'])->name('destroy');
});

4
storage/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

2
storage/app/private/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
storage/app/public/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More