Delete three pockets of dead code, on evidence

1. App\Mail\LoginCodeMail + its Blade view. Login codes go through
   SystemEmail::send('login_code', …), whose TEMPLATES entry carries its own
   plain-text body — the `emails.login-code` view was referenced ONLY from
   LoginCodeMail::content(). Checked all 30 repos: the remaining hits were the
   class itself, two now-unused imports in LoginCodeService, its own render test,
   and one historical plan doc (prose, left).

2. CustomFieldsController::index(). It rendered 'Admin/Tables', and **no
   Tables.vue exists anywhere in the workspace** — so it would have blank-screened
   even if something routed to it, and nothing does. The live editor is
   Admin → User Settings → Fields (UserSettingsController::index builds a strict
   superset of that payload), and /admin/settings/tables already redirects there.
   All four registered actions (store/update/destroy/reorder) still route and were
   re-verified by name after the change. A docblock now records where the UI lives
   so the method does not get re-added.

3. Thirteen stale Vue duplicates in dashboard-expenses and dashboard-ticketing.
   Those pages are served FROM THE SHELL — app.js resolves ./Pages/** before any
   packages/* glob, Ticketing was never globbed at all, and the built manifest
   carried the packages/expenses copies only as shadowed dead weight. All 13 were
   diffed against the shell before deletion: every one is the April original plus
   the July full-width sweep. Two were not merely stale but BROKEN —
   Expenses/Budgets called route('expenses.budgets.index'), a name that does not
   exist, and BudgetEdit still used fiscal_year after the controller moved to
   schoolYears. One genuine difference is noted rather than lost: the snap-in's
   Ticketing/Create gated its "Go to Settings" link on isSiteAdmin while the
   shell's shows it unconditionally, so a non-admin sees a link that 403s — a
   pre-existing shell behaviour (Index.vue does the same), not caused by this.

   This matters because config/inertia.php now registers snap-in page roots for
   tests: a page added ONLY to a snap-in copy would have PASSED tests while 404ing
   in a browser. resources/js/app.js drops the dangling packages/expenses glob,
   with a comment tying its list to config/inertia.php so the two cannot drift.

Bonus: the same trap existed in dashboard-unifi, whose resources/js/Pages was an
empty untracked scaffold that app.js never globbed. Removed. The glob now returns
exactly the four roots app.js resolves — admissions, forms, tuition, verification.

Tests: 83 (was 84; -1 is the deleted mailable render test), doctor exit 0, and a
browser pass over all seven Expenses/Ticketing pages plus /admin/user-settings/fields
and /admin/settings/tables — 200, mounted, zero console errors. /app/ticketing/create
renders the attachments input, proving the shell copy is what serves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 19:05:30 -04:00
parent 0be06694b2
commit 9ce5b45b79
7 changed files with 1 additions and 1997 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "dashboard/ticketing", "name": "dashboard/ticketing",
"description": "Help desk ticketing snap-in for the Dashboard platform", "description": "Help desk ticketing snap-in for the Dashboard platform",
"version": "1.0.2", "version": "1.0.3",
"type": "library", "type": "library",
"require": { "require": {
"php": "^8.2", "php": "^8.2",

View File

@@ -1,131 +0,0 @@
<template>
<AppLayout>
<div class="w-full max-w-2xl">
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
<h1 class="text-2xl font-bold text-gray-900 mt-2">Submit a Ticket</h1>
</div>
<!-- Bootstrap / No groups state -->
<div v-if="isBootstrap" class="bg-amber-50 border border-amber-300 rounded-xl px-5 py-6 text-center">
<p class="text-amber-800 font-semibold text-base mb-2">📦 Ticketing isn't set up yet</p>
<p class="text-sm text-amber-700 mb-4">An admin needs to create at least one group before tickets can be submitted.</p>
<Link v-if="isSiteAdmin" :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700">Go to Settings</Link>
</div>
<form v-else @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Group -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Group <span class="text-red-500">*</span></label>
<select
v-model="form.group_id"
required
class="w-full border-gray-300 rounded-lg text-sm"
>
<option value="">Select a group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
<p v-if="form.errors.group_id" class="text-xs text-red-600 mt-1">{{ form.errors.group_id }}</p>
</div>
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title <span class="text-red-500">*</span></label>
<input
v-model="form.title"
type="text"
required
placeholder="Brief summary of the issue"
class="w-full border-gray-300 rounded-lg text-sm"
/>
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description <span class="text-red-500">*</span></label>
<textarea
v-model="form.description"
required
rows="5"
placeholder="Describe the issue in detail..."
class="w-full border-gray-300 rounded-lg text-sm"
></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select
v-model="form.priority_id"
class="w-full border-gray-300 rounded-lg text-sm"
>
<option :value="null">No priority</option>
<option v-for="p in filteredPriorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- Due Date -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date <span class="text-gray-400 font-normal">(optional)</span></label>
<input
v-model="form.due_date"
type="date"
class="w-full border-gray-300 rounded-lg text-sm"
/>
</div>
<!-- Submit -->
<div class="flex justify-end pt-2">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center gap-2 bg-gray-900 text-white px-5 py-2 rounded-lg text-sm font-medium hover:bg-gray-700 disabled:opacity-60 transition"
>
<span v-if="form.processing">Submitting…</span>
<span v-else>Submit Ticket</span>
</button>
</div>
</form>
</div>
</AppLayout>
</template>
<script setup>
import { computed, watch } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
groups: Array,
priorities: Array,
isBootstrap: Boolean,
isSiteAdmin: Boolean,
})
const form = useForm({
group_id: '',
title: '',
description: '',
priority_id: null,
due_date: '',
})
const filteredPriorities = computed(() => {
if (!form.group_id) return props.priorities.filter(p => !p.group_id)
return props.priorities.filter(p => !p.group_id || p.group_id === Number(form.group_id))
})
// Reset priority if it's no longer valid for the newly selected group
watch(() => form.group_id, () => {
const valid = filteredPriorities.value.some(p => p.id === form.priority_id)
if (!valid) {
form.priority_id = null
}
})
function submit() {
form.post(route('ticketing.store'))
}
</script>

View File

@@ -1,117 +0,0 @@
<template>
<AppLayout>
<div class="w-full max-w-2xl">
<div class="mb-6">
<Link :href="route('ticketing.show', { ticket: ticket.id })" class="text-sm text-indigo-600 hover:underline">← Back to ticket</Link>
<h1 class="text-xl font-bold text-gray-900 mt-2">
Edit <span class="font-mono text-base">{{ ticket.number }}</span>
</h1>
</div>
<form @submit.prevent="submit" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<!-- Title -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input v-model="form.title" type="text" required class="w-full border-gray-300 rounded-lg text-sm" />
<p v-if="form.errors.title" class="text-xs text-red-600 mt-1">{{ form.errors.title }}</p>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea v-model="form.description" rows="6" required class="w-full border-gray-300 rounded-lg text-sm"></textarea>
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
<!-- Status -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select v-model="form.status" class="w-full border-gray-300 rounded-lg text-sm">
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
</div>
<!-- Priority -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<select v-model="form.priority_id" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<!-- Assignee -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
<select v-model="form.assigned_to" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<!-- Project -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Project</label>
<select v-model="form.project_id" class="w-full border-gray-300 rounded-lg text-sm">
<option :value="null">No project</option>
<option v-for="proj in projects" :key="proj.id" :value="proj.id">{{ proj.name }}</option>
</select>
</div>
<!-- Due Date -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Due Date</label>
<input v-model="form.due_date" type="date" class="w-full border-gray-300 rounded-lg text-sm" />
</div>
<div class="flex gap-3 justify-end pt-2">
<Link
:href="route('ticketing.show', { ticket: ticket.id })"
class="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>Cancel</Link>
<button
type="submit"
:disabled="form.processing"
class="px-5 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-700 disabled:opacity-60 transition"
>
{{ form.processing ? 'Saving…' : 'Save Changes' }}
</button>
</div>
</form>
</div>
</AppLayout>
</template>
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link, useForm } from '@inertiajs/vue3'
const props = defineProps({
ticket: Object,
priorities: Array,
agents: Array,
projects: Array,
})
const form = useForm({
title: props.ticket.title,
description: props.ticket.description,
status: props.ticket.status,
priority_id: props.ticket.priority_id,
assigned_to: props.ticket.assigned_to,
project_id: props.ticket.project_id,
due_date: props.ticket.due_date,
})
function submit() {
form.put(route('ticketing.update', { ticket: props.ticket.id }), {
onSuccess: () => {
// redirect happens server side
}
})
}
</script>

View File

@@ -1,798 +0,0 @@
<template>
<div class="flex flex-col lg:flex-row h-screen overflow-hidden bg-gray-100 text-gray-900">
<!-- Bootstrap / first-run state -->
<div v-if="isBootstrap" class="flex flex-col items-center justify-center w-full h-full text-center px-6">
<div class="max-w-md">
<div class="text-5xl mb-4">🎫</div>
<h2 class="text-2xl font-bold text-gray-800 mb-2">Ticketing isn’t set up yet</h2>
<p class="text-gray-500 mb-6">Create your first group in Settings to get started. Default priorities will be seeded automatically.</p>
<Link :href="route('ticketing.settings')" class="inline-block bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-medium hover:bg-indigo-700 transition">Go to Settings</Link>
</div>
</div>
<template v-else>
<aside
class="min-w-0 w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col overflow-y-auto lg:overflow-visible"
:class="showSidebar ? 'flex flex-col' : 'hidden'"
>
<div class="border-b border-gray-200 p-5">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 overflow-hidden">
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400 truncate">Shared inbox</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight truncate">Ticketing</h1>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
@click="showSidebar = false"
>
Done
</button>
<Link
:href="route('ticketing.create')"
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700:bg-indigo-400"
>
New
</Link>
</div>
</div>
</div>
<div class="flex-1 min-w-0 space-y-6 overflow-y-auto p-5">
<section class="space-y-2">
<label class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Group switcher</label>
<select
v-model="activeGroupId"
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm shadow-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
@change="applyFilters({ group_id: normalizeNullable(activeGroupId) })"
>
<option :value="null">All groups</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
</section>
<section class="space-y-2">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Views</h2>
<span class="text-xs text-gray-400">{{ filteredTickets.length }}</span>
</div>
<button
v-for="view in visibleViews"
:key="view.key"
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-3 text-sm transition"
:class="currentFilter === view.key
? 'bg-gray-900 text-white shadow-sm'
: 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ filter: view.key === 'all' ? 'all' : view.key })"
>
<span>{{ view.label }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="currentFilter === view.key ? 'bg-white/15 text-white' : 'bg-gray-200 text-gray-600'"
>
{{ viewCount(view.key) }}
</span>
</button>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Priority</h2>
<button
v-if="filters.priority_id"
type="button"
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
@click="applyFilters({ priority_id: undefined })"
>
Clear
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="priority in priorities"
:key="priority.id"
type="button"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition"
:class="Number(filters.priority_id) === priority.id ? 'shadow-sm' : 'border-gray-200 text-gray-600 hover:bg-gray-100:bg-gray-800'"
:style="priorityChipStyle(priority, Number(filters.priority_id) === priority.id)"
@click="togglePriority(priority.id)"
>
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: priority.color || '#94a3b8' }"></span>
{{ priority.name }}
</button>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Projects</h2>
<button
v-if="filters.project_id"
type="button"
class="text-xs text-gray-400 transition hover:text-gray-600:text-gray-200"
@click="applyFilters({ project_id: undefined })"
>
Clear
</button>
</div>
<div class="space-y-1">
<button
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
:class="!filters.project_id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ project_id: undefined })"
>
<span>All projects</span>
<span class="text-xs text-gray-400">{{ totalProjectCount }}</span>
</button>
<button
v-for="project in projects"
:key="project.id"
type="button"
class="flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-sm transition"
:class="Number(filters.project_id) === project.id ? 'bg-gray-100 text-gray-900' : 'text-gray-600 hover:bg-gray-100:bg-gray-800'"
@click="applyFilters({ project_id: Number(filters.project_id) === project.id ? undefined : project.id })"
>
<span class="truncate">{{ project.name }}</span>
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs text-gray-600">{{ project.ticket_count ?? 0 }}</span>
</button>
</div>
</section>
</div>
</aside>
<main
class="min-w-0 w-full flex-1 border-r border-gray-200 bg-white xl:max-w-[34rem]"
:class="showList ? 'flex' : 'hidden lg:flex'"
>
<div class="flex min-w-0 flex-1 flex-col">
<div class="border-b border-gray-200 p-5">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400">Queue</p>
<h2 class="mt-1 text-xl font-semibold tracking-tight">{{ currentViewTitle }}</h2>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-10 items-center justify-center rounded-xl border border-gray-200 px-4 text-sm font-medium text-gray-600 transition hover:bg-gray-100 lg:hidden"
@click="showSidebar = true"
>
Filters
</button>
<Link
:href="route('ticketing.create')"
class="inline-flex h-10 items-center justify-center rounded-xl bg-gray-900 px-4 text-sm font-medium text-white transition hover:bg-gray-700 lg:hidden"
>
New
</Link>
<div class="hidden rounded-full border border-gray-200 px-3 py-1 text-xs text-gray-500 sm:block">
{{ tickets.total }} total
</div>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 sm:hidden">
{{ tickets.total }} total
</div>
<div class="mt-4 flex flex-col gap-3 sm:flex-row">
<div class="relative flex-1">
<input
v-model="search"
type="text"
placeholder="Search tickets, people, or numbers"
class="w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
/>
</div>
<select
v-model="sortBy"
class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="priority">Priority</option>
<option value="status">Status</option>
</select>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<div v-if="filteredTickets.length === 0" class="flex h-full flex-col items-center justify-center px-6 text-center">
<div class="rounded-3xl border border-dashed border-gray-300 px-8 py-10">
<p class="text-sm font-medium text-gray-500">Nothing matches the current filters.</p>
<p class="mt-2 text-sm text-gray-400">Try a different view, search, or priority chip.</p>
</div>
</div>
<button
v-for="ticket in filteredTickets"
:key="ticket.id"
type="button"
class="flex w-full gap-4 border-b border-gray-100 px-4 py-4 text-left transition hover:bg-gray-50 dark:hover:bg-gray-900/60 sm:px-5"
:class="selectedTicketId === ticket.id ? 'bg-indigo-50/80' : ''"
@click="selectTicket(ticket.id)"
>
<div class="w-1 self-stretch rounded-full" :style="{ backgroundColor: ticket.priority?.color || ticket.group?.color || '#cbd5e1' }"></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-[0.12em] text-gray-400">{{ ticket.number }}</span>
<span :class="badgeClass(ticket.status)" class="inline-flex rounded-full px-2 py-0.5 text-[11px] font-semibold">{{ statusLabel(ticket.status) }}</span>
<span v-if="hasUnread(ticket)" class="h-2.5 w-2.5 rounded-full bg-sky-500"></span>
</div>
<p class="mt-2 truncate text-sm font-semibold text-gray-900">{{ ticket.title }}</p>
<div class="mt-3 flex items-center gap-3 text-xs text-gray-500">
<div class="flex items-center gap-2">
<span class="avatar-ring bg-gray-200 text-gray-700">{{ initials(ticket.submitter?.name || ticket.submitter?.email || '?') }}</span>
<span class="truncate">{{ ticket.submitter?.name || ticket.submitter?.email || 'Unknown' }}</span>
</div>
<span>•</span>
<span>{{ ageLabel(ticket.created_at) }}</span>
</div>
</div>
<div class="flex shrink-0 flex-col items-end justify-between gap-3">
<div class="text-xs font-medium text-gray-500 truncate max-w-[7rem]">{{ ticket.priority?.name || 'No priority' }}</div>
<div class="flex items-center gap-2">
<span class="hidden sm:inline text-[11px] uppercase tracking-[0.16em] text-gray-400">Assignee</span>
<span
class="avatar-ring"
:class="ticket.assignee ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-200 text-gray-500'"
>
{{ ticket.assignee ? initials(ticket.assignee.name || ticket.assignee.email) : '—' }}
</span>
</div>
</div>
</button>
</div>
<div v-if="tickets.last_page > 1" class="flex items-center justify-between border-t border-gray-200 px-5 py-3 text-sm">
<span class="text-gray-500">Page {{ tickets.current_page }} of {{ tickets.last_page }}</span>
<div class="flex items-center gap-2">
<Link
v-if="tickets.prev_page_url"
:href="tickets.prev_page_url"
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
>Prev</Link>
<Link
v-if="tickets.next_page_url"
:href="tickets.next_page_url"
class="rounded-xl border border-gray-200 px-3 py-1.5 text-gray-600 transition hover:bg-gray-100:bg-gray-800"
>Next</Link>
</div>
</div>
</div>
</main>
<section
class="min-w-0 w-full flex-1 flex-col bg-gray-50"
:class="showDetail ? 'flex' : 'hidden lg:flex'"
>
<div v-if="!ticketDetail" class="flex h-full items-center justify-center p-8">
<div class="max-w-md rounded-3xl border border-dashed border-gray-300 bg-white p-10 text-center shadow-sm">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-gray-400">Conversation</p>
<h3 class="mt-3 text-2xl font-semibold tracking-tight">Pick a ticket</h3>
<p class="mt-3 text-sm leading-6 text-gray-500">
The right panel becomes the full thread view: metadata, internal notes, and reply composer included. Miracles do happen.
</p>
</div>
</div>
<template v-else>
<div class="border-b border-gray-200 bg-white px-4 py-5 sm:px-6">
<div class="mb-4 lg:hidden">
<button
type="button"
class="inline-flex items-center rounded-xl border border-gray-200 px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100"
@click="backToList"
>
← Back to list
</button>
</div>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold text-white" :style="{ backgroundColor: ticketDetail.group?.color || '#6366f1' }">{{ ticketDetail.number }}</span>
<span :class="badgeClass(ticketDetail.status)" class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold">{{ statusLabel(ticketDetail.status) }}</span>
<span v-if="ticketDetail.priority" class="inline-flex items-center gap-2 rounded-full border border-gray-200 px-2.5 py-1 text-xs font-medium text-gray-600">
<span class="h-2.5 w-2.5 rounded-full" :style="{ backgroundColor: ticketDetail.priority.color || '#94a3b8' }"></span>
{{ ticketDetail.priority.name }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-start gap-3">
<h3 class="min-w-0 flex-1 text-xl font-semibold tracking-tight text-gray-900 sm:text-2xl">{{ ticketDetail.title }}</h3>
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="shrink-0 text-sm font-medium text-indigo-600 transition hover:text-indigo-500">Open full page</Link>
</div>
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-5">
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Assignee</div>
<div class="mt-2 flex items-center gap-2 text-sm font-medium">
<span class="avatar-ring bg-indigo-100 text-indigo-700">{{ ticketDetail.assignee ? initials(ticketDetail.assignee.name || ticketDetail.assignee.email) : '—' }}</span>
<span class="truncate">{{ ticketDetail.assignee?.name || ticketDetail.assignee?.email || 'Unassigned' }}</span>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Due date</div>
<div class="mt-2 text-sm font-medium">{{ formatDate(ticketDetail.due_date, 'No due date') }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Project</div>
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.project?.name || 'No project' }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Submitter</div>
<div class="mt-2 truncate text-sm font-medium">{{ ticketDetail.submitter?.name || ticketDetail.submitter?.email || 'Unknown' }}</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Opened</div>
<div class="mt-2 text-sm font-medium">{{ ageLabel(ticketDetail.created_at) }}</div>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500">
<span class="truncate">Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
<span aria-hidden="true" class="hidden sm:inline">•</span>
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
<span aria-hidden="true">•</span>
<span>{{ ticketDetail.attachments?.length || 0 }} attachments</span>
</div>
<div v-if="isAgent" class="mt-5 grid gap-3 lg:grid-cols-4">
<select v-model="metaForm.status" class="meta-select" @change="saveMeta">
<option v-for="status in statuses" :key="status.value" :value="status.value">{{ status.label }}</option>
</select>
<select v-model="metaForm.priority_id" class="meta-select" @change="saveMeta">
<option :value="null">No priority</option>
<option v-for="priority in priorities" :key="priority.id" :value="priority.id">{{ priority.name }}</option>
</select>
<select v-model="metaForm.assigned_to" class="meta-select" @change="saveMeta">
<option :value="null">Unassigned</option>
<option v-for="agent in detailAgents" :key="agent.id" :value="agent.id">{{ agent.name || agent.email }}</option>
</select>
<input v-model="metaForm.due_date" type="date" class="meta-select" @change="saveMeta" />
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div v-if="ticketDetail.description" class="mb-6 rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div class="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-400">Description</div>
<p class="whitespace-pre-wrap text-sm leading-6 text-gray-700">{{ ticketDetail.description }}</p>
</div>
<div v-if="!ticketDetail.messages?.length" class="rounded-3xl border border-dashed border-gray-300 bg-white p-8 text-center text-sm text-gray-500">
No messages yet.
</div>
<div v-else class="space-y-4">
<div
v-for="message in ticketDetail.messages"
:key="message.id"
class="flex"
:class="messageAlignment(message)"
>
<div class="max-w-3xl rounded-3xl px-5 py-4 shadow-sm" :class="messageCardClass(message)">
<div class="flex flex-wrap items-center gap-2 text-xs font-medium opacity-80">
<span class="inline-flex items-center gap-2">
<span class="avatar-ring bg-white/70 text-gray-700">{{ initials(messageAuthor(message)) }}</span>
{{ messageRoleLabel(message) }}
</span>
<span>•</span>
<span>{{ message.author?.name || message.author_email || 'Unknown' }}</span>
<span>•</span>
<span>{{ preciseDate(message.created_at) }}</span>
</div>
<p class="mt-3 whitespace-pre-wrap text-sm leading-6">{{ message.body }}</p>
<div v-if="message.attachments?.length" class="mt-4 flex flex-wrap gap-2">
<a
v-for="attachment in message.attachments"
:key="attachment.id"
:href="route('ticketing.attachments.show', { attachment: attachment.id })"
class="inline-flex items-center gap-2 rounded-full border border-current/20 px-3 py-1.5 text-xs font-medium transition hover:bg-white/30:bg-gray-900/30"
target="_blank"
>
📎 {{ attachment.filename }}
</a>
</div>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 bg-white px-4 py-5 sm:px-6">
<div v-if="isAgent" class="mb-4 inline-flex rounded-2xl bg-gray-100 p-1">
<button
type="button"
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
:class="replyMode === 'reply' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'"
@click="replyMode = 'reply'"
>Reply</button>
<button
type="button"
class="rounded-2xl px-4 py-2 text-sm font-medium transition"
:class="replyMode === 'internal' ? 'bg-amber-500 text-white shadow-sm' : 'text-gray-500'"
@click="replyMode = 'internal'"
>Internal note</button>
</div>
<form @submit.prevent="sendMessage" class="space-y-3">
<textarea
v-model="messageForm.body"
rows="4"
required
class="w-full rounded-3xl border px-4 py-3 text-sm outline-none transition focus:ring-2"
:class="replyMode === 'internal'
? 'border-amber-300 bg-amber-50 text-amber-950 focus:border-amber-400 focus:ring-amber-100:ring-amber-500/20'
: 'border-gray-200 bg-gray-50 text-gray-900 focus:border-indigo-400 focus:ring-indigo-100:border-indigo-500:ring-indigo-500/20'"
:placeholder="replyMode === 'internal' ? 'Internal note for agents only…' : 'Reply to the thread…'"
></textarea>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="text-xs text-gray-400">
{{ replyMode === 'internal' ? 'Only agents can see internal notes.' : 'Replies are visible in the ticket thread.' }}
</div>
<button
type="submit"
:disabled="messageForm.processing"
class="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-white transition disabled:cursor-not-allowed disabled:opacity-60"
:class="replyMode === 'internal' ? 'bg-amber-500 hover:bg-amber-600' : 'bg-gray-900 hover:bg-gray-700:bg-indigo-400'"
>
{{ messageForm.processing ? 'Sending…' : replyMode === 'internal' ? 'Save note' : 'Send reply' }}
</button>
</div>
</form>
</div>
</template>
</section>
</template>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { Link, router, useForm } from '@inertiajs/vue3'
const props = defineProps({
tickets: Object,
groups: Array,
priorities: Array,
projects: Array,
isAgent: Boolean,
filters: Object,
ticketDetail: Object,
detailAgents: Array,
viewCounts: Object,
isBootstrap: Boolean,
})
const search = ref('')
const sortBy = ref('newest')
const activeGroupId = ref(props.filters?.group_id ? Number(props.filters.group_id) : null)
const replyMode = ref('reply')
const showSidebar = ref(!props.ticketDetail)
const visibleViews = computed(() => {
const base = [
{ key: 'all', label: 'All Open' },
{ key: 'mine', label: 'Mine' },
{ key: 'unassigned', label: 'Unassigned' },
{ key: 'pending', label: 'Pending' },
{ key: 'resolved', label: 'Resolved' },
]
return props.isAgent ? base : base.filter((view) => ['all', 'pending', 'resolved'].includes(view.key))
})
const currentFilter = computed(() => props.filters?.filter || 'all')
const selectedTicketId = computed(() => props.ticketDetail?.id ?? (props.filters?.detail ? Number(props.filters.detail) : null))
const currentViewTitle = computed(() => visibleViews.value.find((view) => view.key === currentFilter.value)?.label || 'Tickets')
const totalProjectCount = computed(() => props.projects.reduce((sum, project) => sum + Number(project.ticket_count || 0), 0))
const showList = computed(() => !props.ticketDetail)
const showDetail = computed(() => !!props.ticketDetail)
const filteredTickets = computed(() => {
const term = search.value.trim().toLowerCase()
const priorityWeight = Object.fromEntries(
[...props.priorities].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)).map((priority, index, arr) => [priority.id, arr.length - index])
)
let items = [...(props.tickets?.data || [])]
if (term) {
items = items.filter((ticket) => {
const haystack = [
ticket.number,
ticket.title,
ticket.status,
ticket.submitter?.name,
ticket.submitter?.email,
ticket.assignee?.name,
ticket.assignee?.email,
ticket.priority?.name,
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(term)
})
}
items.sort((a, b) => {
if (sortBy.value === 'oldest') {
return new Date(a.created_at) - new Date(b.created_at)
}
if (sortBy.value === 'priority') {
return (priorityWeight[b.priority_id] || 0) - (priorityWeight[a.priority_id] || 0) || new Date(b.created_at) - new Date(a.created_at)
}
if (sortBy.value === 'status') {
return statusSortValue(a.status) - statusSortValue(b.status) || new Date(b.created_at) - new Date(a.created_at)
}
return new Date(b.created_at) - new Date(a.created_at)
})
return items
})
const metaForm = useForm({
status: props.ticketDetail?.status ?? 'open',
priority_id: props.ticketDetail?.priority_id ?? null,
assigned_to: props.ticketDetail?.assigned_to ?? null,
due_date: props.ticketDetail?.due_date ?? null,
})
const messageForm = useForm({
body: '',
is_internal: false,
})
watch(
() => props.ticketDetail,
(detail) => {
replyMode.value = 'reply'
showSidebar.value = !detail
metaForm.defaults({
status: detail?.status ?? 'open',
priority_id: detail?.priority_id ?? null,
assigned_to: detail?.assigned_to ?? null,
due_date: detail?.due_date ?? null,
})
metaForm.reset()
},
{ immediate: true }
)
const statuses = [
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'pending', label: 'Pending' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
function normalizeNullable(value) {
return value === '' || value === null || value === undefined ? undefined : Number(value)
}
function buildParams(overrides = {}) {
const params = {
...props.filters,
...overrides,
}
Object.keys(params).forEach((key) => {
if (params[key] === undefined || params[key] === null || params[key] === '') {
delete params[key]
}
})
return params
}
function applyFilters(overrides = {}) {
const params = buildParams({ ...overrides, detail: overrides.detail === undefined ? props.filters?.detail : overrides.detail })
router.get(route('ticketing.index'), params, {
preserveState: true,
preserveScroll: true,
replace: true,
})
}
function togglePriority(priorityId) {
applyFilters({ priority_id: Number(props.filters.priority_id) === priorityId ? undefined : priorityId })
}
function selectTicket(ticketId) {
router.get(route('ticketing.index'), buildParams({ detail: ticketId }), {
preserveState: true,
preserveScroll: true,
replace: true,
only: ['ticketDetail', 'detailAgents', 'filters'],
})
}
function backToList() {
router.get(route('ticketing.index'), buildParams({ detail: undefined }), {
preserveState: true,
preserveScroll: true,
replace: true,
only: ['ticketDetail', 'detailAgents', 'filters'],
})
}
function saveMeta() {
if (!props.ticketDetail) return
metaForm.transform((data) => ({
...data,
priority_id: normalizeNullable(data.priority_id),
assigned_to: normalizeNullable(data.assigned_to),
due_date: data.due_date || null,
})).put(route('ticketing.update', { ticket: props.ticketDetail.id }), {
preserveScroll: true,
onSuccess: () => {
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
},
})
}
function sendMessage() {
if (!props.ticketDetail) return
messageForm.transform((data) => ({
...data,
is_internal: props.isAgent && replyMode.value === 'internal',
})).post(route('ticketing.messages.store', { ticket: props.ticketDetail.id }), {
preserveScroll: true,
onSuccess: () => {
messageForm.reset()
router.reload({ only: ['tickets', 'ticketDetail', 'detailAgents'] })
},
})
}
function viewCount(key) {
return props.viewCounts?.[key] ?? 0
}
function initials(value) {
return String(value || '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
}
function badgeClass(status) {
return {
open: 'bg-sky-100 text-sky-700',
in_progress: 'bg-violet-100 text-violet-700',
pending: 'bg-amber-100 text-amber-700',
resolved: 'bg-emerald-100 text-emerald-700',
closed: 'bg-gray-200 text-gray-700',
}[status] || 'bg-gray-200 text-gray-700'
}
function priorityChipStyle(priority, active) {
const color = priority.color || '#94a3b8'
return active
? { borderColor: color, backgroundColor: `${color}18`, color }
: {}
}
function statusLabel(status) {
return {
open: 'Open',
in_progress: 'In Progress',
pending: 'Pending',
resolved: 'Resolved',
closed: 'Closed',
}[status] || status
}
function statusSortValue(status) {
return {
open: 1,
in_progress: 2,
pending: 3,
resolved: 4,
closed: 5,
}[status] || 99
}
function ageLabel(value) {
return relativeTime(value)
}
function preciseDate(value) {
if (!value) return 'Unknown time'
return new Date(value).toLocaleString()
}
function formatDate(value, fallback = '—') {
if (!value) return fallback
return new Date(value).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
function relativeTime(value) {
if (!value) return '—'
const date = new Date(value)
const diff = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000))
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`
return `${Math.floor(diff / 604800)}w ago`
}
function hasUnread(ticket) {
if (!ticket || !props.ticketDetail) return false
return ticket.id !== props.ticketDetail.id && ['open', 'pending'].includes(ticket.status)
}
function messageAlignment(message) {
if (message.is_internal) return 'justify-center'
return isSubmitterMessage(message) ? 'justify-start' : 'justify-end'
}
function messageCardClass(message) {
if (message.is_internal) {
return 'border border-amber-200 bg-amber-50 text-amber-950'
}
if (isSubmitterMessage(message)) {
return 'border border-gray-200 bg-white text-gray-800'
}
return 'bg-gray-900 text-white'
}
function isSubmitterMessage(message) {
return Number(message.user_id) === Number(props.ticketDetail?.submitter_id)
}
function messageRoleLabel(message) {
if (message.is_internal) return 'Internal note'
return isSubmitterMessage(message) ? 'Submitter' : 'Agent reply'
}
function messageAuthor(message) {
return message.author?.name || message.author?.email || message.author_email || 'Unknown'
}
</script>
<style scoped>
.avatar-ring {
@apply inline-flex h-8 w-8 items-center justify-center rounded-full text-[11px] font-semibold;
}
.meta-select {
@apply w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 focus:ring-indigo-500/20;
}
</style>

View File

@@ -1,76 +0,0 @@
<template>
<AppLayout>
<div class="w-full">
<div class="mb-6 flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-900">My Tickets</h1>
<Link :href="route('ticketing.create')" class="inline-flex items-center gap-1 bg-gray-900 text-white text-sm px-4 py-2 rounded-lg hover:bg-gray-700 transition">
+ Submit Ticket
</Link>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 divide-y divide-gray-100">
<div v-if="tickets.data.length === 0" class="p-8 text-center text-gray-400 text-sm">
You haven't submitted any tickets yet.
</div>
<Link
v-for="ticket in tickets.data"
:key="ticket.id"
:href="route('ticketing.show', { ticket: ticket.id })"
class="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition"
>
<span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0 mt-0.5"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>{{ ticket.number }}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ ticket.title }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ ticket.group?.name }} · {{ timeAgo(ticket.created_at) }}</p>
</div>
<span :class="statusClass(ticket.status)" class="text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0">
{{ statusLabel(ticket.status) }}
</span>
</Link>
</div>
<!-- Pagination -->
<div v-if="tickets.last_page > 1" class="mt-4 flex justify-center gap-4 text-sm">
<Link v-if="tickets.prev_page_url" :href="tickets.prev_page_url" class="text-indigo-600 hover:underline">← Previous</Link>
<span class="text-gray-500">{{ tickets.current_page }} / {{ tickets.last_page }}</span>
<Link v-if="tickets.next_page_url" :href="tickets.next_page_url" class="text-indigo-600 hover:underline">Next →</Link>
</div>
</div>
</AppLayout>
</template>
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue'
import { Link } from '@inertiajs/vue3'
defineProps({ tickets: Object })
function statusLabel(status) {
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
return map[status] || status
}
function statusClass(status) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700',
pending: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
}
return map[status] || 'bg-gray-100 text-gray-600'
}
function timeAgo(dateStr) {
const date = new Date(dateStr)
const now = new Date()
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>

View File

@@ -1,560 +0,0 @@
<template>
<div class="max-w-5xl mx-auto py-8 px-4">
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
<h1 class="text-xl font-bold text-gray-900 mt-2 sm:text-2xl">Ticketing Settings</h1>
</div>
<div v-if="isBootstrap" class="mb-6 px-5 py-4 bg-amber-50 border border-amber-300 rounded-xl">
<h2 class="text-base font-semibold text-amber-800 mb-1">🚀 First-Run Setup</h2>
<p class="text-sm text-amber-700">
No groups exist yet. Create your first group below to get started.
Default priorities (Low, Medium, High, Urgent) will be seeded automatically.
</p>
</div>
<div v-if="$page.props.flash?.success" class="mb-4 px-4 py-2 bg-green-50 border border-green-200 text-green-700 rounded-lg text-sm">
{{ $page.props.flash.success }}
</div>
<div v-if="$page.props.errors?.priority" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ $page.props.errors.priority }}
</div>
<div v-if="$page.props.errors?.project" class="mb-4 px-4 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{{ $page.props.errors.project }}
</div>
<div class="flex gap-1 border-b border-gray-200 mb-6 overflow-x-auto scrollbar-none -mx-4 px-4 sm:mx-0 sm:px-0">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
:class="[
'shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 transition',
activeTab === tab.key
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
]"
>{{ tab.label }}</button>
</div>
<div v-if="activeTab === 'groups'">
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">Groups</h2>
<button @click="showAddGroup = !showAddGroup" class="text-sm text-indigo-600 hover:underline">
{{ showAddGroup ? 'Cancel' : '+ Add Group' }}
</button>
</div>
<div v-if="showAddGroup" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<h3 class="text-sm font-semibold text-gray-700">New Group</h3>
<form @submit.prevent="submitGroup" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="groupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prefix (e.g. IT)</label>
<input v-model="groupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
<input v-model="groupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="groupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end">
<button type="submit" :disabled="groupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Create Group
</button>
</div>
</form>
</div>
<div class="space-y-3">
<div v-if="groups.length === 0" class="text-sm text-gray-400 italic">No groups yet.</div>
<div
v-for="group in groups"
:key="group.id"
class="flex items-center justify-between px-4 py-3 bg-white border border-gray-200 rounded-xl"
>
<div class="flex items-center gap-3">
<span class="w-3 h-3 rounded-full" :style="{ backgroundColor: group.color }"></span>
<div>
<p class="font-medium text-sm text-gray-800">{{ group.name }}</p>
<p class="text-xs text-gray-400">{{ group.prefix }} · {{ group.email_address || 'No email' }}</p>
</div>
</div>
<button @click="startEditGroup(group)" class="text-xs text-indigo-600 hover:underline">Edit</button>
</div>
</div>
<div v-if="editingGroup" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700">Edit: {{ editingGroup.name }}</h3>
<form @submit.prevent="submitEditGroup" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editGroupForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prefix</label>
<input v-model="editGroupForm.prefix" required type="text" maxlength="10" class="w-full text-sm border-gray-300 rounded-lg uppercase" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email Address</label>
<input v-model="editGroupForm.email_address" type="email" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="editGroupForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
<button type="button" @click="editingGroup = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="submit" :disabled="editGroupForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
</div>
</form>
</div>
</div>
<div v-if="activeTab === 'agents'">
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">Agents</h2>
<button @click="showAddAgent = !showAddAgent" class="text-sm text-indigo-600 hover:underline">
{{ showAddAgent ? 'Cancel' : '+ Add Agent' }}
</button>
</div>
<div v-if="showAddAgent" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitAgent" class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label class="block text-xs text-gray-500 mb-1">User ID</label>
<input v-model="agentForm.user_id" required type="number" placeholder="User ID" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Group</label>
<select v-model="agentForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
<option value="">Select group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Role</label>
<select v-model="agentForm.role" class="w-full text-sm border-gray-300 rounded-lg">
<option value="agent">Agent</option>
<option value="manager">Manager</option>
</select>
</div>
<div class="col-span-1 sm:col-span-3 flex justify-end">
<button type="submit" :disabled="agentForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Add Agent
</button>
</div>
</form>
</div>
<div class="space-y-2">
<div v-if="agents.length === 0" class="text-sm text-gray-400 italic">No agents configured yet.</div>
<div
v-for="access in agents"
:key="access.id"
class="flex items-center justify-between px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
>
<div>
<p class="text-sm font-medium text-gray-800">{{ access.user?.name || 'User #' + access.user_id }}</p>
<p class="text-xs text-gray-400">{{ access.user?.email }} · {{ access.group?.name || 'Unknown Group' }} · <span class="capitalize">{{ access.role }}</span></p>
</div>
<button @click="openRemoveAgentModal(access)" class="text-xs text-red-500 hover:underline">Remove</button>
</div>
</div>
</div>
<div v-if="activeTab === 'priorities'">
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">Priority Levels</h2>
<button @click="showAddPriority = !showAddPriority" class="text-sm text-indigo-600 hover:underline">
{{ showAddPriority ? 'Cancel' : '+ Add Priority' }}
</button>
</div>
<div v-if="showAddPriority" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitPriority" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="priorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="priorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input v-model="priorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
<input v-model="priorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Group (blank = global)</label>
<select v-model="priorityForm.group_id" class="w-full text-sm border-gray-300 rounded-lg">
<option :value="null">Global</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end">
<button type="submit" :disabled="priorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Create Priority
</button>
</div>
</form>
</div>
<div class="space-y-2">
<div v-if="priorities.length === 0" class="text-sm text-gray-400 italic">No priorities defined yet.</div>
<div
v-for="p in priorities"
:key="p.id"
class="flex items-center gap-3 px-4 py-2.5 bg-white border border-gray-200 rounded-xl"
>
<span class="w-3 h-3 rounded-full flex-shrink-0" :style="{ backgroundColor: p.color }"></span>
<div class="flex-1">
<p class="text-sm font-medium text-gray-800">{{ p.name }}</p>
<p v-if="p.description" class="text-xs text-gray-400">{{ p.description }}</p>
</div>
<span class="text-xs text-gray-400">{{ p.group_id ? 'Group-specific' : 'Global' }}</span>
<div class="flex items-center gap-3">
<button @click="startEditPriority(p)" class="text-xs text-indigo-600 hover:underline">Edit</button>
<button @click="openDeletePriorityModal(p)" class="text-xs text-red-500 hover:underline">Delete</button>
</div>
</div>
</div>
<div v-if="editingPriority" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700">Edit Priority: {{ editingPriority.name }}</h3>
<form @submit.prevent="submitEditPriority" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editPriorityForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input v-model="editPriorityForm.color" type="color" class="h-9 w-full border-gray-300 rounded-lg cursor-pointer" />
</div>
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label>
<input v-model="editPriorityForm.description" type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Sort Order</label>
<input v-model="editPriorityForm.sort_order" type="number" min="0" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Scope</label>
<input :value="editingPriority.group_id ? 'Group-specific' : 'Global'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
<button type="button" @click="editingPriority = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="submit" :disabled="editPriorityForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
</div>
</form>
</div>
</div>
<div v-if="activeTab === 'projects'">
<div class="flex flex-wrap justify-between items-center gap-2 mb-4">
<h2 class="text-lg font-semibold text-gray-800">Projects</h2>
<button @click="showAddProject = !showAddProject" class="text-sm text-indigo-600 hover:underline">
{{ showAddProject ? 'Cancel' : '+ Add Project' }}
</button>
</div>
<div v-if="showAddProject" class="bg-gray-50 rounded-xl p-4 mb-5 space-y-3">
<form @submit.prevent="submitProject" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Group</label>
<select v-model="projectForm.group_id" required class="w-full text-sm border-gray-300 rounded-lg">
<option value="">Select group</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Status</label>
<select v-model="projectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
</div>
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="projectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label>
<textarea v-model="projectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end">
<button type="submit" :disabled="projectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">
Create Project
</button>
</div>
</form>
</div>
<div class="space-y-2">
<div v-if="projects.length === 0" class="text-sm text-gray-400 italic">No projects defined yet.</div>
<div
v-for="project in projects"
:key="project.id"
class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl"
>
<div class="flex-1">
<p class="text-sm font-medium text-gray-800">{{ project.name }}</p>
<p class="text-xs text-gray-400">{{ groups.find(g => g.id === project.group_id)?.name || 'Unknown Group' }} · {{ project.status }}</p>
<p v-if="project.description" class="text-xs text-gray-500 mt-1">{{ project.description }}</p>
</div>
<div class="flex items-center gap-3">
<button @click="startEditProject(project)" class="text-xs text-indigo-600 hover:underline">Edit</button>
<button @click="openDeleteProjectModal(project)" class="text-xs text-red-500 hover:underline">Delete</button>
</div>
</div>
</div>
<div v-if="editingProject" class="mt-4 bg-gray-50 rounded-xl p-4 space-y-3">
<h3 class="text-sm font-semibold text-gray-700">Edit Project: {{ editingProject.name }}</h3>
<form @submit.prevent="submitEditProject" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Name</label>
<input v-model="editProjectForm.name" required type="text" class="w-full text-sm border-gray-300 rounded-lg" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Status</label>
<select v-model="editProjectForm.status" class="w-full text-sm border-gray-300 rounded-lg">
<option value="active">Active</option>
<option value="archived">Archived</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Group</label>
<input :value="groups.find(g => g.id === editingProject.group_id)?.name || 'Unknown Group'" disabled type="text" class="w-full text-sm border-gray-300 rounded-lg opacity-70" />
</div>
<div class="col-span-1 sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Description</label>
<textarea v-model="editProjectForm.description" rows="3" class="w-full text-sm border-gray-300 rounded-lg"></textarea>
</div>
<div class="col-span-1 sm:col-span-2 flex justify-end gap-2">
<button type="button" @click="editingProject = null" class="text-sm text-gray-500 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="submit" :disabled="editProjectForm.processing" class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 disabled:opacity-60">Save</button>
</div>
</form>
</div>
</div>
</div>
<div v-if="removeAgentModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900">Remove agent?</h3>
<p class="mt-2 text-sm text-gray-600">
Remove <strong>{{ pendingAgentRemoval?.user?.name || ('User #' + pendingAgentRemoval?.user_id) }}</strong>
from <strong>{{ pendingAgentRemoval?.group?.name }}</strong>?
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" @click="closeRemoveAgentModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="button" @click="confirmRemoveAgent" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Remove</button>
</div>
</div>
</div>
<div v-if="deletePriorityModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900">Delete priority?</h3>
<p class="mt-2 text-sm text-gray-600">
Delete <strong>{{ pendingPriorityDelete?.name }}</strong>? This cannot be undone.
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" @click="closeDeletePriorityModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="button" @click="confirmDeletePriority" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
</div>
</div>
</div>
<div v-if="deleteProjectModalOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-gray-900">Delete project?</h3>
<p class="mt-2 text-sm text-gray-600">
Delete <strong>{{ pendingProjectDelete?.name }}</strong>? This cannot be undone.
</p>
<div class="mt-6 flex justify-end gap-2">
<button type="button" @click="closeDeleteProjectModal" class="text-sm text-gray-600 px-3 py-2 rounded-lg border border-gray-300 hover:bg-gray-100">Cancel</button>
<button type="button" @click="confirmDeleteProject" class="bg-red-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-red-700">Delete</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Link, useForm, router } from '@inertiajs/vue3'
const props = defineProps({
groups: Array,
agents: Array,
priorities: Array,
projects: Array,
myGroupIds: Array,
isBootstrap: Boolean,
isSiteAdmin: Boolean,
})
const activeTab = ref('groups')
const tabs = [
{ key: 'groups', label: 'Groups' },
{ key: 'agents', label: 'Agents' },
{ key: 'priorities', label: 'Priorities' },
{ key: 'projects', label: 'Projects' },
]
const showAddGroup = ref(false)
const showAddAgent = ref(false)
const showAddPriority = ref(false)
const showAddProject = ref(false)
const editingGroup = ref(null)
const editingPriority = ref(null)
const editingProject = ref(null)
const removeAgentModalOpen = ref(false)
const pendingAgentRemoval = ref(null)
const deletePriorityModalOpen = ref(false)
const pendingPriorityDelete = ref(null)
const deleteProjectModalOpen = ref(false)
const pendingProjectDelete = ref(null)
const groupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
const editGroupForm = useForm({ name: '', email_address: '', color: '#6366f1', prefix: '' })
const agentForm = useForm({ user_id: '', group_id: '', role: 'agent' })
const priorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
const editPriorityForm = useForm({ name: '', color: '#6b7280', description: '', sort_order: 0, group_id: null })
const projectForm = useForm({ group_id: '', name: '', description: '', status: 'active' })
const editProjectForm = useForm({ name: '', description: '', status: 'active' })
function submitGroup() {
groupForm.post(route('ticketing.settings.groups.store'), {
onSuccess: () => { showAddGroup.value = false; groupForm.reset() }
})
}
function startEditGroup(group) {
editingGroup.value = group
editGroupForm.name = group.name
editGroupForm.email_address = group.email_address || ''
editGroupForm.color = group.color
editGroupForm.prefix = group.prefix
}
function submitEditGroup() {
editGroupForm.put(route('ticketing.settings.groups.update', { group: editingGroup.value.id }), {
onSuccess: () => { editingGroup.value = null }
})
}
function submitAgent() {
agentForm.post(route('ticketing.settings.agents.store'), {
onSuccess: () => { showAddAgent.value = false; agentForm.reset() }
})
}
function openRemoveAgentModal(access) {
pendingAgentRemoval.value = access
removeAgentModalOpen.value = true
}
function closeRemoveAgentModal() {
removeAgentModalOpen.value = false
pendingAgentRemoval.value = null
}
function confirmRemoveAgent() {
if (!pendingAgentRemoval.value) return
router.delete(route('ticketing.settings.agents.destroy', { access: pendingAgentRemoval.value.id }), {
onFinish: closeRemoveAgentModal,
})
}
function submitPriority() {
priorityForm.post(route('ticketing.settings.priorities.store'), {
onSuccess: () => { showAddPriority.value = false; priorityForm.reset() }
})
}
function startEditPriority(priority) {
editingPriority.value = priority
editPriorityForm.name = priority.name
editPriorityForm.color = priority.color
editPriorityForm.description = priority.description || ''
editPriorityForm.sort_order = priority.sort_order
editPriorityForm.group_id = priority.group_id
}
function submitEditPriority() {
editPriorityForm.put(route('ticketing.settings.priorities.update', { priority: editingPriority.value.id }), {
onSuccess: () => { editingPriority.value = null }
})
}
function openDeletePriorityModal(priority) {
pendingPriorityDelete.value = priority
deletePriorityModalOpen.value = true
}
function closeDeletePriorityModal() {
deletePriorityModalOpen.value = false
pendingPriorityDelete.value = null
}
function confirmDeletePriority() {
if (!pendingPriorityDelete.value) return
router.delete(route('ticketing.settings.priorities.destroy', { priority: pendingPriorityDelete.value.id }), {
onFinish: closeDeletePriorityModal,
})
}
function submitProject() {
projectForm.post(route('ticketing.settings.projects.store'), {
onSuccess: () => { showAddProject.value = false; projectForm.reset() }
})
}
function startEditProject(project) {
editingProject.value = project
editProjectForm.name = project.name
editProjectForm.description = project.description || ''
editProjectForm.status = project.status
}
function submitEditProject() {
editProjectForm.put(route('ticketing.settings.projects.update', { project: editingProject.value.id }), {
onSuccess: () => { editingProject.value = null }
})
}
function openDeleteProjectModal(project) {
pendingProjectDelete.value = project
deleteProjectModalOpen.value = true
}
function closeDeleteProjectModal() {
deleteProjectModalOpen.value = false
pendingProjectDelete.value = null
}
function confirmDeleteProject() {
if (!pendingProjectDelete.value) return
router.delete(route('ticketing.settings.projects.destroy', { project: pendingProjectDelete.value.id }), {
onFinish: closeDeleteProjectModal,
})
}
</script>

View File

@@ -1,314 +0,0 @@
<template>
<AppLayout>
<div class="w-full">
<!-- Header -->
<div class="mb-6">
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
</div>
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
<!-- Ticket Header -->
<div class="p-5 border-b border-gray-200">
<div class="flex items-start gap-3 flex-wrap">
<span
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
>
{{ ticket.number }}
</span>
<!-- Title (editable inline for agents) -->
<div class="flex-1 min-w-0">
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
<input
v-model="titleEdit"
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
@keyup.enter="saveTitle"
@keyup.esc="editingTitle = false"
/>
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
</div>
<h1
v-else
class="text-xl font-semibold text-gray-900"
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
@click="isAgent && startEditTitle()"
>
{{ ticket.title }}
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
</h1>
</div>
</div>
<!-- Meta row -->
<div class="flex flex-wrap items-center gap-3 mt-3">
<!-- Status -->
<div>
<select
v-if="isAgent"
v-model="metaForm.status"
@change="saveMeta"
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
:class="statusClass(metaForm.status)"
>
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
{{ statusLabel(ticket.status) }}
</span>
</div>
<!-- Priority -->
<div>
<select
v-if="isAgent"
v-model="metaForm.priority_id"
@change="saveMeta"
class="text-xs border border-gray-200 rounded-full px-3 py-1"
>
<option :value="null">No priority</option>
<option v-for="p in priorities" :key="p.id" :value="p.id">
{{ p.name }}
</option>
</select>
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600">
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
{{ ticket.priority.name }}
</span>
</div>
<!-- Assignee -->
<div v-if="isAgent">
<select
v-model="metaForm.assigned_to"
@change="saveMeta"
class="text-xs border border-gray-200 rounded-full px-3 py-1"
>
<option :value="null">Unassigned</option>
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<!-- Due date -->
<div class="flex items-center gap-1 text-xs text-gray-500">
<span>📅</span>
<input
v-if="isAgent"
v-model="metaForm.due_date"
type="date"
@change="saveMeta"
class="text-xs border-0 bg-transparent cursor-pointer p-0"
/>
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
</div>
<!-- Edit / Delete actions -->
<div class="ml-auto flex gap-2">
<Link
v-if="isAgent"
:href="route('ticketing.edit', { ticket: ticket.id })"
class="text-xs text-indigo-600 hover:underline"
>Edit</Link>
<button
v-if="isManager"
@click="confirmDelete"
class="text-xs text-red-500 hover:underline"
>Delete</button>
</div>
</div>
</div>
<!-- Message Thread -->
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
No messages yet.
</div>
<div
v-for="msg in ticket.messages"
:key="msg.id"
:class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
>
<div
:class="[
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
msg.is_internal
? 'bg-amber-50 border border-amber-200 text-amber-900'
: isOwnMessage(msg)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-800'
]"
>
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
<span v-if="msg.is_internal">🔒 Internal Note · </span>
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
<span>· {{ timeAgo(msg.created_at) }}</span>
</div>
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
</div>
</div>
</div>
<!-- Reply Area -->
<div class="border-t border-gray-200 p-5">
<!-- Tab switcher (agents only) -->
<div v-if="isAgent" class="flex gap-2 mb-3">
<button
@click="replyTab = 'reply'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
>Reply to submitter</button>
<button
@click="replyTab = 'internal'"
:class="[
'text-xs px-3 py-1.5 rounded-md font-medium transition',
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
>🔒 Internal Note</button>
</div>
<form @submit.prevent="sendMessage">
<textarea
v-model="messageForm.body"
required
rows="3"
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
:class="[
'w-full rounded-lg text-sm border',
replyTab === 'internal'
? 'border-amber-300 bg-amber-50'
: 'border-gray-300'
]"
></textarea>
<div class="flex items-center justify-between mt-3">
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
<input type="file" class="hidden" @change="attachFile" />
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
</label>
<button
type="submit"
:disabled="messageForm.processing"
:class="[
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
replyTab === 'internal'
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
: 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
]"
>
{{ messageForm.processing ? 'Sending…' : 'Send' }}
</button>
</div>
</form>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref } from 'vue'
import { Link, useForm, router } from '@inertiajs/vue3'
import AppLayout from '@/Layouts/AppLayout.vue'
const props = defineProps({
ticket: Object,
isAgent: Boolean,
isManager: Boolean,
agents: Array,
priorities: Array,
})
const replyTab = ref('reply')
const editingTitle = ref(false)
const titleEdit = ref(props.ticket.title)
const statuses = [
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'pending', label: 'Pending' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
const metaForm = useForm({
status: props.ticket.status,
priority_id: props.ticket.priority_id,
assigned_to: props.ticket.assigned_to,
due_date: props.ticket.due_date,
})
const messageForm = useForm({
body: '',
is_internal: false,
})
function isOwnMessage(msg) {
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
}
function startEditTitle() {
titleEdit.value = props.ticket.title
editingTitle.value = true
}
function saveTitle() {
router.put(route('ticketing.update', { ticket: props.ticket.id }), { title: titleEdit.value }, {
onSuccess: () => { editingTitle.value = false }
})
}
function saveMeta() {
metaForm.put(route('ticketing.update', { ticket: props.ticket.id }))
}
function sendMessage() {
messageForm.is_internal = replyTab.value === 'internal'
messageForm.post(route('ticketing.messages.store', { ticket: props.ticket.id }), {
onSuccess: () => { messageForm.body = '' }
})
}
function attachFile(event) {
const file = event.target.files[0]
if (!file) return
const data = new FormData()
data.append('file', file)
router.post(route('ticketing.attachments.store', { ticket: props.ticket.id }), data)
}
function confirmDelete() {
if (confirm(`Delete ticket ${props.ticket.number}? This cannot be undone.`)) {
router.delete(route('ticketing.destroy', { ticket: props.ticket.id }))
}
}
function statusLabel(status) {
const map = { open: 'Open', in_progress: 'In Progress', pending: 'Pending', resolved: 'Resolved', closed: 'Closed' }
return map[status] || status
}
function statusClass(status) {
const map = {
open: 'bg-blue-100 text-blue-700',
in_progress: 'bg-purple-100 text-purple-700',
pending: 'bg-yellow-100 text-yellow-700',
resolved: 'bg-green-100 text-green-700',
closed: 'bg-gray-100 text-gray-500',
}
return map[status] || 'bg-gray-100 text-gray-600'
}
function timeAgo(dateStr) {
const date = new Date(dateStr)
const now = new Date()
const diff = Math.floor((now - date) / 1000)
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
</script>