feat: add quick-create functionality for client categories in the schedule form
This commit is contained in:
@@ -88,21 +88,17 @@ class BrandContractController extends Controller
|
|||||||
{
|
{
|
||||||
$categoryId = $request->integer('category_id');
|
$categoryId = $request->integer('category_id');
|
||||||
$clientId = $request->integer('client_id');
|
$clientId = $request->integer('client_id');
|
||||||
|
$dateStr = $request->input('date');
|
||||||
|
|
||||||
if (! $categoryId) {
|
if (! $categoryId || empty($dateStr)) {
|
||||||
return response()->json(null);
|
return response()->json(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect the category and its parent chain (so blocking a parent also blocks children)
|
$date = $dateStr ? \Carbon\Carbon::parse($dateStr) : today();
|
||||||
$category = ClientCategory::find($categoryId);
|
|
||||||
$checkIds = [$categoryId];
|
|
||||||
if ($category?->parent_id) {
|
|
||||||
$checkIds[] = $category->parent_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contract = BrandContract::with(['client', 'category'])
|
$contract = BrandContract::with(['client', 'category'])
|
||||||
->active()
|
->active($date)
|
||||||
->whereIn('category_id', $checkIds)
|
->where('category_id', $categoryId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $contract) {
|
if (! $contract) {
|
||||||
|
|||||||
@@ -46,6 +46,22 @@ class ClientCategoryController extends Controller
|
|||||||
return back()->with('success', '🏷️ Category added!');
|
return back()->with('success', '🏷️ Category added!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function quickStore(Request $request): \Illuminate\Http\JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['slug'] = \Illuminate\Support\Str::slug($validated['name']);
|
||||||
|
if (ClientCategory::where('slug', $validated['slug'])->exists()) {
|
||||||
|
$validated['slug'] .= '-' . substr(uniqid(), -4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category = ClientCategory::create($validated);
|
||||||
|
|
||||||
|
return response()->json(['id' => $category->id, 'name' => $category->name, 'icon' => '🏷️']);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(Request $request, ClientCategory $category): RedirectResponse
|
public function update(Request $request, ClientCategory $category): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|||||||
@@ -40,20 +40,33 @@ class EventController extends Controller
|
|||||||
$events = Event::with('client')->orderBy('start_time')->get();
|
$events = Event::with('client')->orderBy('start_time')->get();
|
||||||
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
$clients = Client::where('status', 'active')->orderBy('name')->get();
|
||||||
$categoryTree = $this->categoryTree();
|
$categoryTree = $this->categoryTree();
|
||||||
|
|
||||||
|
$contracts = BrandContract::select('client_id', 'title')->get();
|
||||||
|
$contents = ContentBrief::select('client_id', 'title')->get();
|
||||||
|
|
||||||
|
foreach ($events as $e) {
|
||||||
|
$e->has_contract = $e->client_id && $contracts->contains(fn($c) => $c->client_id === $e->client_id && stripos($c->title, $e->title) !== false);
|
||||||
|
$e->has_content = $e->client_id && $contents->contains(fn($c) => $c->client_id === $e->client_id && stripos($c->title, $e->title) !== false);
|
||||||
|
}
|
||||||
|
|
||||||
// Format for FullCalendar
|
// Format for FullCalendar
|
||||||
$calendarEvents = $events->map(fn (Event $e) => [
|
$calendarEvents = $events->map(fn (Event $e) => [
|
||||||
'id' => $e->id,
|
'id' => $e->id,
|
||||||
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
|
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
|
||||||
'start' => $e->start_time->toIso8601String(),
|
'start' => $e->start_time->format('Y-m-d\TH:i:s'),
|
||||||
'end' => $e->end_time?->toIso8601String(),
|
'end' => $e->end_time?->format('Y-m-d\TH:i:s'),
|
||||||
'backgroundColor' => $e->color,
|
'backgroundColor' => $e->color,
|
||||||
'borderColor' => $e->color,
|
'borderColor' => $e->color,
|
||||||
'extendedProps' => [
|
'extendedProps' => [
|
||||||
'status' => $e->status->value,
|
'status' => $e->status->value,
|
||||||
'client' => $e->client?->name,
|
'client' => $e->client?->name,
|
||||||
'type' => $e->event_type,
|
'type' => $e->event_type,
|
||||||
'priority' => $e->is_priority,
|
'priority' => $e->is_priority,
|
||||||
|
'location' => $e->location,
|
||||||
|
'description' => $e->description,
|
||||||
|
'notes' => $e->notes,
|
||||||
|
'has_contract' => $e->has_contract,
|
||||||
|
'has_content' => $e->has_content,
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -192,21 +205,35 @@ class EventController extends Controller
|
|||||||
|
|
||||||
public function apiList(): JsonResponse
|
public function apiList(): JsonResponse
|
||||||
{
|
{
|
||||||
$events = Event::with('client')->get()->map(fn (Event $e) => [
|
$eventsList = Event::with('client')->get();
|
||||||
'id' => $e->id,
|
$contracts = BrandContract::select('client_id', 'title')->get();
|
||||||
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
|
$contents = ContentBrief::select('client_id', 'title')->get();
|
||||||
'start' => $e->start_time->toIso8601String(),
|
|
||||||
'end' => $e->end_time?->toIso8601String(),
|
$events = $eventsList->map(function (Event $e) use ($contracts, $contents) {
|
||||||
'backgroundColor' => $e->color,
|
$hasContract = $e->client_id && $contracts->contains(fn($c) => $c->client_id === $e->client_id && stripos($c->title, $e->title) !== false);
|
||||||
'borderColor' => $e->color,
|
$hasContent = $e->client_id && $contents->contains(fn($c) => $c->client_id === $e->client_id && stripos($c->title, $e->title) !== false);
|
||||||
'allDay' => $e->is_all_day,
|
|
||||||
'extendedProps' => [
|
return [
|
||||||
'status' => $e->status->value,
|
'id' => $e->id,
|
||||||
'client' => $e->client?->name,
|
'title' => ($e->is_priority ? '⭐ ' : '') . $e->title,
|
||||||
'type' => $e->event_type,
|
'start' => $e->start_time->format('Y-m-d\TH:i:s'),
|
||||||
'priority' => $e->is_priority,
|
'end' => $e->end_time?->format('Y-m-d\TH:i:s'),
|
||||||
],
|
'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,
|
||||||
|
'location' => $e->location,
|
||||||
|
'description' => $e->description,
|
||||||
|
'notes' => $e->notes,
|
||||||
|
'has_contract' => $hasContract,
|
||||||
|
'has_content' => $hasContent,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return response()->json($events);
|
return response()->json($events);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,12 @@ class BrandContract extends Model
|
|||||||
|
|
||||||
// ── Scopes ───────────────────────────────────────────────────
|
// ── Scopes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
public function scopeActive($query)
|
public function scopeActive($query, $date = null)
|
||||||
{
|
{
|
||||||
|
$date = $date ?? today();
|
||||||
return $query->where('is_active', true)
|
return $query->where('is_active', true)
|
||||||
->where('start_date', '<=', today())
|
->where('start_date', '<=', $date)
|
||||||
->where('end_date', '>=', today());
|
->where('end_date', '>=', $date);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Target Date</label>
|
<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">
|
<input type="date" name="target_date" value="{{ old('target_date') }}" class="input-soft" @change="checkContract()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +116,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 pt-1">
|
<div class="flex flex-col sm:flex-row gap-3 pt-1">
|
||||||
<button type="submit" class="btn-sakura flex-1 py-3"
|
<button type="submit" class="btn-sakura flex-1 py-3"
|
||||||
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }">🎬 Create Brief</button>
|
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }"
|
||||||
|
:disabled="contractWarning">🎬 Create Brief</button>
|
||||||
<a href="{{ route('content.index') }}"
|
<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">
|
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
|
Cancel
|
||||||
@@ -135,13 +136,32 @@ function contractCheck(checkUrl) {
|
|||||||
contractMessage: '',
|
contractMessage: '',
|
||||||
async checkContract() {
|
async checkContract() {
|
||||||
if (!this.categoryId) { this.contractWarning = false; return; }
|
if (!this.categoryId) { this.contractWarning = false; return; }
|
||||||
const params = new URLSearchParams({ category_id: this.categoryId, client_id: this.clientId || '' });
|
let dateVal = '';
|
||||||
const res = await fetch(checkUrl + '?' + params);
|
const targetDateEl = document.querySelector('input[name="target_date"]');
|
||||||
const data = await res.json();
|
if (targetDateEl && targetDateEl.value) {
|
||||||
if (data) {
|
dateVal = targetDateEl.value;
|
||||||
this.contractWarning = true;
|
}
|
||||||
this.contractMessage = `"${data.title}" — ${data.client_name} holds exclusive rights in the ${data.category} category until ${data.end_date} (${data.days_left} days left).`;
|
if (!dateVal) {
|
||||||
} else {
|
this.contractWarning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
category_id: this.categoryId,
|
||||||
|
client_id: this.clientId || '',
|
||||||
|
date: dateVal
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(checkUrl + '?' + params);
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.title) {
|
||||||
|
this.contractWarning = true;
|
||||||
|
this.contractMessage = `"${data.title}" — ${data.client_name} holds exclusive rights in the ${data.category} category until ${data.end_date} (${data.days_left} days left).`;
|
||||||
|
} else {
|
||||||
|
this.contractWarning = false;
|
||||||
|
this.contractMessage = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
this.contractWarning = false;
|
this.contractWarning = false;
|
||||||
this.contractMessage = '';
|
this.contractMessage = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="cat-card p-4 lg:p-8">
|
<div class="cat-card p-4 lg:p-8">
|
||||||
<form action="{{ route('schedule.store') }}" method="POST" id="create-event-form"
|
<form action="{{ route('schedule.store') }}" method="POST" id="create-event-form"
|
||||||
x-data="contractCheck('{{ route('contracts.check') }}')"
|
x-data="contractCheck('{{ route('contracts.check', [], false) }}')"
|
||||||
class="space-y-5">
|
class="space-y-5">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="start_time" id="start_time_hidden">
|
<input type="hidden" name="start_time" id="start_time_hidden">
|
||||||
@@ -31,7 +31,100 @@
|
|||||||
@error('title') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
@error('title') <p class="text-xs text-red-500 mt-1">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4" x-data="{
|
||||||
|
isNew: false,
|
||||||
|
saving: false,
|
||||||
|
newName: '', newEmail: '', newPhone: '', newCompany: '',
|
||||||
|
errMsg: '',
|
||||||
|
isNewCat: false,
|
||||||
|
savingCat: false,
|
||||||
|
newCatName: '',
|
||||||
|
catErrMsg: '',
|
||||||
|
onClientChange(e) {
|
||||||
|
this.isNew = (e.target.value === '__new__');
|
||||||
|
if (!this.isNew) {
|
||||||
|
this.clientId = e.target.value;
|
||||||
|
$dispatch('contract-check');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveNewClient() {
|
||||||
|
if (!this.newName.trim()) { this.errMsg = 'Name is required.'; return; }
|
||||||
|
this.saving = true; this.errMsg = '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('name', this.newName);
|
||||||
|
fd.append('email', this.newEmail);
|
||||||
|
fd.append('phone', this.newPhone);
|
||||||
|
fd.append('company', this.newCompany);
|
||||||
|
fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route('clients.quick-store', [], false) }}', {
|
||||||
|
method:'POST',
|
||||||
|
body: fd,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
// Add new option & select it
|
||||||
|
const sel = document.getElementById('client-select');
|
||||||
|
const opt = new Option(data.name, data.id, true, true);
|
||||||
|
sel.add(opt, sel.options[1]);
|
||||||
|
sel.value = data.id;
|
||||||
|
sel.dispatchEvent(new Event('change'));
|
||||||
|
this.isNew = false;
|
||||||
|
this.newName = this.newEmail = this.newPhone = this.newCompany = '';
|
||||||
|
} else {
|
||||||
|
this.errMsg = data.message || 'Could not create client.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.errMsg = 'An unexpected error occurred.';
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCategoryChange(e) {
|
||||||
|
this.isNewCat = (e.target.value === '__new__');
|
||||||
|
if (!this.isNewCat) {
|
||||||
|
this.categoryId = e.target.value;
|
||||||
|
this.checkContract();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveNewCategory() {
|
||||||
|
if (!this.newCatName.trim()) { this.catErrMsg = 'Category name is required.'; return; }
|
||||||
|
this.savingCat = true; this.catErrMsg = '';
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('name', this.newCatName);
|
||||||
|
fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ route('client-categories.quick-store', [], false) }}', {
|
||||||
|
method:'POST',
|
||||||
|
body: fd,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const sel = document.getElementById('category-select');
|
||||||
|
const opt = new Option(data.icon + ' ' + data.name, data.id, true, true);
|
||||||
|
sel.add(opt, sel.options[1]);
|
||||||
|
sel.value = data.id;
|
||||||
|
sel.dispatchEvent(new Event('change'));
|
||||||
|
this.isNewCat = false;
|
||||||
|
this.newCatName = '';
|
||||||
|
} else {
|
||||||
|
this.catErrMsg = data.message || 'Could not create category.';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.catErrMsg = 'An unexpected error occurred.';
|
||||||
|
} finally {
|
||||||
|
this.savingCat = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}">
|
||||||
{{-- Type --}}
|
{{-- Type --}}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Type</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Type</label>
|
||||||
@@ -45,44 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Client --}}
|
{{-- Client --}}
|
||||||
<div x-data="{
|
<div>
|
||||||
isNew: false,
|
|
||||||
saving: false,
|
|
||||||
newName: '', newEmail: '', newPhone: '', newCompany: '',
|
|
||||||
errMsg: '',
|
|
||||||
onClientChange(e) {
|
|
||||||
this.isNew = (e.target.value === '__new__');
|
|
||||||
if (!this.isNew) {
|
|
||||||
clientId = e.target.value;
|
|
||||||
$dispatch('contract-check');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async saveNewClient() {
|
|
||||||
if (!this.newName.trim()) { this.errMsg = 'Name is required.'; return; }
|
|
||||||
this.saving = true; this.errMsg = '';
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('name', this.newName);
|
|
||||||
fd.append('email', this.newEmail);
|
|
||||||
fd.append('phone', this.newPhone);
|
|
||||||
fd.append('company', this.newCompany);
|
|
||||||
fd.append('_token', document.querySelector('meta[name=csrf-token]').content);
|
|
||||||
const res = await fetch('{{ route('clients.quick-store') }}', { method:'POST', body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
// Add new option & select it
|
|
||||||
const sel = document.getElementById('client-select');
|
|
||||||
const opt = new Option(data.name, data.id, true, true);
|
|
||||||
sel.add(opt, sel.options[1]);
|
|
||||||
sel.value = data.id;
|
|
||||||
sel.dispatchEvent(new Event('change'));
|
|
||||||
this.isNew = false;
|
|
||||||
this.newName = this.newEmail = this.newPhone = this.newCompany = '';
|
|
||||||
} else {
|
|
||||||
this.errMsg = data.message ?? 'Could not create client.';
|
|
||||||
}
|
|
||||||
this.saving = false;
|
|
||||||
}
|
|
||||||
}">
|
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Client</label>
|
||||||
<select name="client_id" id="client-select" class="input-soft"
|
<select name="client_id" id="client-select" class="input-soft"
|
||||||
x-model="clientId"
|
x-model="clientId"
|
||||||
@@ -95,47 +151,48 @@
|
|||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Quick New Client mini-form --}}
|
{{-- Quick New Client mini-form --}}
|
||||||
<div x-show="isNew" x-transition
|
<div x-show="isNew" x-transition style="display: none;"
|
||||||
class="mt-3 bg-purple-50 rounded-2xl p-4 space-y-3 border border-purple-100">
|
class="sm:col-span-2 bg-purple-50 rounded-2xl p-4 space-y-3 border border-purple-100">
|
||||||
<p class="text-xs font-bold text-purple-700">🐱 New Client Basic Info</p>
|
<p class="text-xs font-bold text-purple-700">🐱 New Client Basic Info</p>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Name <span class="text-pink-400">*</span></label>
|
<label class="block text-xs font-semibold text-gray-600 mb-1">Name <span class="text-pink-400">*</span></label>
|
||||||
<input type="text" x-model="newName" class="input-soft text-sm" placeholder="Client / brand name">
|
<input type="text" x-model="newName" class="input-soft text-sm" placeholder="Client / brand name">
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Company</label>
|
|
||||||
<input type="text" x-model="newCompany" class="input-soft text-sm" placeholder="Company name">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Email</label>
|
|
||||||
<input type="email" x-model="newEmail" class="input-soft text-sm" placeholder="contact@brand.com">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold text-gray-600 mb-1">Phone</label>
|
|
||||||
<input type="text" x-model="newPhone" class="input-soft text-sm" placeholder="+62 …">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p x-show="errMsg" x-text="errMsg" class="text-xs text-red-500"></p>
|
<div>
|
||||||
<div class="flex gap-2 pt-1">
|
<label class="block text-xs font-semibold text-gray-600 mb-1">Company</label>
|
||||||
<button type="button" @click="saveNewClient()" :disabled="saving"
|
<input type="text" x-model="newCompany" class="input-soft text-sm" placeholder="Company name">
|
||||||
class="btn-sakura text-sm px-4 py-2" x-text="saving ? 'Saving…' : '✓ Add & Select'"></button>
|
</div>
|
||||||
<button type="button" @click="isNew = false; $el.closest('div').querySelector('select').value = ''"
|
<div>
|
||||||
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
|
<label class="block text-xs font-semibold text-gray-600 mb-1">Email</label>
|
||||||
Cancel
|
<input type="email" x-model="newEmail" class="input-soft text-sm" placeholder="contact@brand.com">
|
||||||
</button>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 mb-1">Phone</label>
|
||||||
|
<input type="text" x-model="newPhone" class="input-soft text-sm" placeholder="+62 …">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-purple-400">Status will be set to Active automatically.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p x-show="errMsg" x-text="errMsg" class="text-xs text-red-500"></p>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button type="button" @click="saveNewClient()" :disabled="saving"
|
||||||
|
class="btn-sakura text-sm px-4 py-2" x-text="saving ? 'Saving…' : '✓ Add & Select'"></button>
|
||||||
|
<button type="button" @click="isNew = false; document.getElementById('client-select').value = ''"
|
||||||
|
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-purple-400">Status will be set to Active automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Category --}}
|
{{-- Category --}}
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Category</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Category</label>
|
||||||
<select name="category_id" class="input-soft" x-model="categoryId" @change="checkContract()">
|
<select name="category_id" id="category-select" class="input-soft" x-model="categoryId" @change="onCategoryChange($event)">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
|
<option value="__new__" class="font-semibold text-pink-600">+ Create New Category…</option>
|
||||||
@foreach($categoryTree as $parent)
|
@foreach($categoryTree as $parent)
|
||||||
<option value="{{ $parent->id }}" {{ old('category_id') == $parent->id ? 'selected' : '' }}>
|
<option value="{{ $parent->id }}" {{ old('category_id') == $parent->id ? 'selected' : '' }}>
|
||||||
{{ $parent->icon }} {{ $parent->name }}
|
{{ $parent->icon }} {{ $parent->name }}
|
||||||
@@ -149,6 +206,25 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Quick New Category mini-form --}}
|
||||||
|
<div x-show="isNewCat" x-transition style="display: none;"
|
||||||
|
class="sm:col-span-2 bg-pink-50 rounded-2xl p-4 space-y-3 border border-pink-100">
|
||||||
|
<p class="text-xs font-bold text-pink-700">🏷️ New Category</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 mb-1">Category Name <span class="text-pink-400">*</span></label>
|
||||||
|
<input type="text" x-model="newCatName" class="input-soft text-sm" placeholder="e.g. Sponsored Post">
|
||||||
|
</div>
|
||||||
|
<p x-show="catErrMsg" x-text="catErrMsg" class="text-xs text-red-500"></p>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button type="button" @click="saveNewCategory()" :disabled="savingCat"
|
||||||
|
class="btn-sakura text-sm px-4 py-2" x-text="savingCat ? 'Saving…' : '✓ Add & Select'"></button>
|
||||||
|
<button type="button" @click="isNewCat = false; document.getElementById('category-select').value = ''"
|
||||||
|
class="text-sm px-4 py-2 rounded-full border border-pink-200 text-gray-500 hover:bg-pink-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{-- Contract warning --}}
|
{{-- Contract warning --}}
|
||||||
<div x-show="contractWarning" x-transition
|
<div x-show="contractWarning" x-transition
|
||||||
class="sm:col-span-2 rounded-2xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm"
|
class="sm:col-span-2 rounded-2xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm"
|
||||||
@@ -163,7 +239,7 @@
|
|||||||
Start Date <span class="text-pink-400">*</span>
|
Start Date <span class="text-pink-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input type="date" id="start_date" class="input-soft"
|
<input type="date" id="start_date" class="input-soft"
|
||||||
value="{{ old('_start_date') }}" required>
|
value="{{ old('_start_date') }}" required @change="checkContract()">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-gray-700 mb-1">Start Time</label>
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Start Time</label>
|
||||||
@@ -287,7 +363,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 pt-1">
|
<div class="flex flex-col sm:flex-row gap-3 pt-1">
|
||||||
<button type="submit" class="btn-sakura flex-1 py-3"
|
<button type="submit" class="btn-sakura flex-1 py-3"
|
||||||
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }">
|
:class="{ 'opacity-50 cursor-not-allowed': contractWarning }"
|
||||||
|
:disabled="contractWarning">
|
||||||
🐾 Schedule It!
|
🐾 Schedule It!
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ route('schedule.index') }}"
|
<a href="{{ route('schedule.index') }}"
|
||||||
@@ -301,6 +378,47 @@
|
|||||||
|
|
||||||
@push('scripts')
|
@push('scripts')
|
||||||
<script>
|
<script>
|
||||||
|
function contractCheck(checkUrl) {
|
||||||
|
return {
|
||||||
|
categoryId: '',
|
||||||
|
clientId: '',
|
||||||
|
contractWarning: false,
|
||||||
|
contractMessage: '',
|
||||||
|
async checkContract() {
|
||||||
|
if (!this.categoryId) { this.contractWarning = false; return; }
|
||||||
|
let dateVal = '';
|
||||||
|
const startDateEl = document.getElementById('start_date');
|
||||||
|
if (startDateEl && startDateEl.value) {
|
||||||
|
dateVal = startDateEl.value;
|
||||||
|
}
|
||||||
|
if (!dateVal) {
|
||||||
|
this.contractWarning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
category_id: this.categoryId,
|
||||||
|
client_id: this.clientId || '',
|
||||||
|
date: dateVal
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await fetch(checkUrl + '?' + params);
|
||||||
|
if (!res.ok) throw new Error('Network error');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.title) {
|
||||||
|
this.contractWarning = true;
|
||||||
|
this.contractMessage = `"${data.title}" — ${data.client_name} holds exclusive rights in the ${data.category} category until ${data.end_date} (${data.days_left} days left).`;
|
||||||
|
} else {
|
||||||
|
this.contractWarning = false;
|
||||||
|
this.contractMessage = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.contractWarning = false;
|
||||||
|
this.contractMessage = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('create-event-form').addEventListener('submit', function (e) {
|
document.getElementById('create-event-form').addEventListener('submit', function (e) {
|
||||||
const startDate = document.getElementById('start_date').value;
|
const startDate = document.getElementById('start_date').value;
|
||||||
const startTime = document.getElementById('start_time_input').value || '00:00';
|
const startTime = document.getElementById('start_time_input').value || '00:00';
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="flex gap-2 overflow-x-auto pb-2 mb-4 -mx-4 px-4 lg:mx-0 lg:px-0">
|
<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)
|
@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 cursor-pointer hover:border-pink-300 transition-colors"
|
<div class="shrink-0 flex items-center gap-2 bg-white rounded-2xl border border-pink-100 px-3 py-2 shadow-sm cursor-pointer hover:border-pink-300 transition-colors"
|
||||||
onclick="openAlertEventModal({{ json_encode(['id' => $event->id, 'title' => $event->title, 'type' => $event->event_type, 'status' => $event->status->value, 'client' => $event->client?->name, 'start' => $event->start_time->toIso8601String(), 'end' => $event->end_time?->toIso8601String()]) }})">
|
onclick="openAlertEventModal({{ json_encode(['id' => $event->id, 'title' => $event->title, 'type' => $event->event_type, 'status' => $event->status->value, 'client' => $event->client?->name, 'start' => $event->start_time->format('d M Y H:i'), 'end' => $event->end_time?->format('d M Y H:i'), 'location' => $event->location, 'description' => $event->description, 'notes' => $event->notes, 'has_contract' => $event->has_contract, 'has_content' => $event->has_content]) }})">
|
||||||
<div class="w-1.5 h-8 rounded-full shrink-0" style="background:{{ $event->color }}"></div>
|
<div class="w-1.5 h-8 rounded-full shrink-0" style="background:{{ $event->color }}"></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-semibold text-gray-700 whitespace-nowrap">{{ $event->title }}</div>
|
<div class="text-xs font-semibold text-gray-700 whitespace-nowrap">{{ $event->title }}</div>
|
||||||
@@ -106,8 +106,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
start_time: info.event.startStr,
|
start_time: info.event.startStr ? info.event.startStr.substring(0, 19) : null,
|
||||||
end_time: info.event.endStr || null,
|
end_time: info.event.endStr ? info.event.endStr.substring(0, 19) : null,
|
||||||
}),
|
}),
|
||||||
}).then(r => r.ok ? window.KolManager.notify('Event rescheduled! 🐾') : info.revert());
|
}).then(r => r.ok ? window.KolManager.notify('Event rescheduled! 🐾') : info.revert());
|
||||||
},
|
},
|
||||||
@@ -124,11 +124,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
const e = info.event, p = e.extendedProps;
|
const e = info.event, p = e.extendedProps;
|
||||||
document.getElementById('modal-title').textContent = e.title;
|
document.getElementById('modal-title').textContent = e.title;
|
||||||
document.getElementById('modal-body').innerHTML = `
|
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-24 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>
|
<div class="flex gap-2"><span class="text-gray-400 w-24 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>` : ''}
|
${p.client ? `<div class="flex gap-2"><span class="text-gray-400 w-24 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>
|
${p.location ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Location</span><span>${p.location}</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>` : ''}
|
<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Start</span><span>${e.start?.toLocaleString()}</span></div>
|
||||||
|
${e.end ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">End</span><span>${e.end.toLocaleString()}</span></div>` : ''}
|
||||||
|
${p.description ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Description</span><span class="break-words">${p.description}</span></div>` : ''}
|
||||||
|
${p.notes ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Notes</span><span class="break-words">${p.notes}</span></div>` : ''}
|
||||||
|
${p.has_contract ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Contract</span><span class="text-pink-500 font-semibold">Yes</span></div>` : ''}
|
||||||
|
${p.has_content ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Content Bd</span><span class="text-pink-500 font-semibold">Yes</span></div>` : ''}
|
||||||
`;
|
`;
|
||||||
const editUrl = `/schedule/${e.id}/edit`;
|
const editUrl = `/schedule/${e.id}/edit`;
|
||||||
document.getElementById('modal-actions').innerHTML = `
|
document.getElementById('modal-actions').innerHTML = `
|
||||||
@@ -156,14 +161,19 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
|
|
||||||
function openAlertEventModal(e) {
|
function openAlertEventModal(e) {
|
||||||
document.getElementById('modal-title').textContent = e.title;
|
document.getElementById('modal-title').textContent = e.title;
|
||||||
const startStr = e.start ? new Date(e.start).toLocaleString() : '—';
|
const startStr = e.start ? e.start : '—';
|
||||||
const endStr = e.end ? new Date(e.end).toLocaleString() : null;
|
const endStr = e.end ? e.end : null;
|
||||||
document.getElementById('modal-body').innerHTML = `
|
document.getElementById('modal-body').innerHTML = `
|
||||||
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Type</span><span>${e.type}</span></div>
|
<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Type</span><span>${e.type}</span></div>
|
||||||
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Status</span><span>${e.status}</span></div>
|
<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Status</span><span>${e.status}</span></div>
|
||||||
${e.client ? `<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Client</span><span>${e.client}</span></div>` : ''}
|
${e.client ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Client</span><span>${e.client}</span></div>` : ''}
|
||||||
<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">Start</span><span>${startStr}</span></div>
|
${e.location ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Location</span><span>${e.location}</span></div>` : ''}
|
||||||
${endStr ? `<div class="flex gap-2"><span class="text-gray-400 w-16 shrink-0">End</span><span>${endStr}</span></div>` : ''}
|
<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Start</span><span>${startStr}</span></div>
|
||||||
|
${endStr ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">End</span><span>${endStr}</span></div>` : ''}
|
||||||
|
${e.description ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Description</span><span class="break-words">${e.description}</span></div>` : ''}
|
||||||
|
${e.notes ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Notes</span><span class="break-words">${e.notes}</span></div>` : ''}
|
||||||
|
${e.has_contract ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Contract</span><span class="text-pink-500 font-semibold">Yes</span></div>` : ''}
|
||||||
|
${e.has_content ? `<div class="flex gap-2"><span class="text-gray-400 w-24 shrink-0">Content Bd</span><span class="text-pink-500 font-semibold">Yes</span></div>` : ''}
|
||||||
`;
|
`;
|
||||||
document.getElementById('modal-actions').innerHTML = `
|
document.getElementById('modal-actions').innerHTML = `
|
||||||
<a href="/schedule/${e.id}/edit" class="btn-sakura text-sm flex-1 text-center py-2">Edit</a>
|
<a href="/schedule/${e.id}/edit" class="btn-sakura text-sm flex-1 text-center py-2">Edit</a>
|
||||||
|
|||||||
@@ -27,13 +27,14 @@ Route::post('/logout', [GateController::class, 'logout'])->name('gate.logout');
|
|||||||
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('/', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
// CRM – Clients
|
// CRM – Clients
|
||||||
Route::resource('clients', ClientController::class);
|
|
||||||
Route::post('/clients/quick', [ClientController::class, 'quickStore'])->name('clients.quick-store');
|
Route::post('/clients/quick', [ClientController::class, 'quickStore'])->name('clients.quick-store');
|
||||||
|
Route::resource('clients', ClientController::class);
|
||||||
Route::post('/clients/{client}/avatar', [ClientController::class, 'uploadAvatar'])->name('clients.upload-avatar');
|
Route::post('/clients/{client}/avatar', [ClientController::class, 'uploadAvatar'])->name('clients.upload-avatar');
|
||||||
Route::delete('/clients/{client}/avatar', [ClientController::class, 'deleteAvatar'])->name('clients.delete-avatar');
|
Route::delete('/clients/{client}/avatar', [ClientController::class, 'deleteAvatar'])->name('clients.delete-avatar');
|
||||||
|
|
||||||
// Client Categories
|
// Client Categories
|
||||||
Route::prefix('client-categories')->name('client-categories.')->group(function (): void {
|
Route::prefix('client-categories')->name('client-categories.')->group(function (): void {
|
||||||
|
Route::post('/quick', [ClientCategoryController::class, 'quickStore'])->name('quick-store');
|
||||||
Route::get('/', [ClientCategoryController::class, 'index'])->name('index');
|
Route::get('/', [ClientCategoryController::class, 'index'])->name('index');
|
||||||
Route::post('/', [ClientCategoryController::class, 'store'])->name('store');
|
Route::post('/', [ClientCategoryController::class, 'store'])->name('store');
|
||||||
Route::put('/{category}', [ClientCategoryController::class, 'update'])->name('update');
|
Route::put('/{category}', [ClientCategoryController::class, 'update'])->name('update');
|
||||||
|
|||||||
Reference in New Issue
Block a user