10 Commits

Author SHA1 Message Date
e24124b6d4 Notification categories now declare who they are FOR
A parent opening their notification settings saw every category in the system -
Bosch, Inbox, Inventory, Oncall, Signage, Ticketing, Tools - and an ALWAYS SENT
list including "Impersonation attempts locked out", "Notice of Employment
posted" and "New school account". None of it could ever reach them. The registry
was a static catalogue with no per-user filter and the page rendered 100% of it
to 100% of users.

Permission answers "may you administer this". It never answered "might this be
about you". Categories now declare an audience - everyone / staff / student /
family - resolved RECORD-AWARE via the shell's App\Support\Audience, so on-call
staff count as staff without naming a role and a stale role with no record does
not.

ONE RESOLVER, NOT TWO. NotificationAudience delegates cohortsFor/matches/
familyRoles to the shell behind a class_exists guard, with a legacy path so this
ship-dark beta still degrades rather than fataling on an older shell. The local
FAMILY_ROLES_WITHOUT_ADMISSIONS = ['parent'] fallback is DELETED - it had already
drifted, and on a shell without admissions it would have silently stopped
counting applicant/open-house/homeschool as family.

FILTERED IN selfServe(), NOT THE CONTROLLER. ChannelFloor counts the same list
the page shows to enforce "you cannot switch off your last channel". Filtering
in the controller instead would have let a parent be REFUSED A SAVE because of a
Bosch category they cannot see. A test pins the two lists as the same set for
parent, staff and student, with a non-empty guard so the comparison cannot pass
vacuously.

THE THROW COULD NOT DO ITS JOB. Registering without an audience throws outside
production and falls back to staff (fail-closed) inside it - but every snap-in
wraps register() in a bare catch(\Throwable) and most register in a LOOP, so a
missing argument is swallowed AND silently drops every remaining category for
that app. Mutation M8 proves it: deleting one audience argument loses all six
task categories with nothing logged. EXPECTED_CATEGORY_COUNT = 43 is the only
thing that catches it, because a per-app try/catch guarantees this never
surfaces as an error.

Classification corrected three of my proposed defaults against the evidence:
ticketing.replied is staff+student, not family - a parent cannot raise a ticket
(submitter_id is hardcoded to Auth::id(), there is no inbound-email path, and
canLogin() bars parents). system.school_account_created is staff+student - its
own description says "a new staff member OR STUDENT". forms.* is everyone,
because forms go to staff self-service AND to families.

13 mutations, 12 red. M11 SURVIVED and is reported rather than papered over: the
explicit unknown-cohort loop in normalise() is genuinely redundant, since the
trailing array_filter also drops a typo. M11b removes both and goes red, so the
behaviour is pinned by something.

Super-admin: no bypass in the send path, deliberately - a staff-targeted notice
must not reach the diagnostic super accounts. A bypass exists ONLY in catalogue
visibility, because impersonation_lockout is addressed to administrators rather
than to a staff record, so a super would otherwise be hidden mail they do get.
It sits in the shared filter so the floor stays in lockstep.

Also: the digest registers with coalesce: false. It goes out through Notify like
anything else, so the 5-minute coalescer could hold it and fold it into a
summary-of-summaries. A digest is already a batch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 21:36:19 -04:00
82892deafe Mark as beta: it is off in production
`stability` is what dashboard:doctor and the ship-dark contract read, and it said
"stable" for an app that is not running in production. That is worse than redundant —
it is doctor reporting the wrong thing about the deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-29 15:01:23 -04:00
afbbd3fcde Reject non-scalar ids with 422 instead of 500 (#10)
`group_id: [14]` — a one-element array of a REAL group id — returned a 500.
The reporter's analysis was right: `exists` given an array validates that
EVERY element exists, which is multi-select behaviour, so an array of one
valid id passed the rule and reached the controller. findOrFail() then
returned a Collection and ->nextTicketNumber() on it fatalled.

What hid it is that the rule looked like it was working: `['a','b']` was
correctly rejected by `exists` with a clean 422. Only arrays made of valid
ids got through.

Adding `integer` ahead of `exists` fixes the reported field, but the hole
was a property of `exists`, not of group_id — so every id on the endpoint
had it. Fixed all nine sites across both controllers: store()'s group_id
and priority_id, update()'s priority_id/project_id/group_id, and
storeAgent/storeProject/storeEmailConnection's group_id (plus user_id,
which fed straight into an access check).

Tests pin the reported repro, the equivalent on priority_id, and both
shapes that must keep behaving: a nonexistent id still 422s (so the exists
check cannot be dropped later while the array tests still pass) and a
normal scalar create still succeeds. Verified the array test FAILS with the
rules reverted — it reproduces the 500 rather than merely passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 19:24:12 -04:00
5a83263f7a Gate my-tickets and prefs; leave attachments to their own finer check
my-tickets and prefs.save carried only `app.access:ticketing`, which means "you hold ANY
ticketing.* permission" — so ticketing.settings alone opened the submitter's own surface.
They now require ticketing.view or ticketing.manage, matching the either-or this route
file already uses everywhere else; ticketing.view is literally described as "Submit
tickets and view own ticket history", so it is the right key.

attachments.show stays UNGATED on purpose. TicketAttachmentController::show already
authorises submitter-or-agent for that specific attachment, which is finer than any
permission can be — a key in front of it would either duplicate the check or contradict
it, and contradiction is what this consolidation exists to remove.

Part of consolidating Central onto one authorisation mechanism: permissions, held by
groups.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 16:53:29 -04:00
4719cbab8b Ticketing joins the notification platform (closes #8)
No ticketing.* category existed, so the shared Notification Log offered a filter for
every other app and nothing for this one. Verified after this change: 2 categories
register, 39 total.

THIS WAS NOT A MAIL SWEEP. Every other app's integration redirected an existing Mail::
call; here there was nothing to redirect. TicketMessageController::notifySubmitter()
was an empty stub:

    // Placeholder for email notification
    // In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…));

So nobody has ever been told their ticket was answered. The reporter read the absence
of a Notifications directory as "not wired up", and it was more literal than that.

TWO CATEGORIES, matching the two moments somebody is waiting on another person:

  ticketing.replied  → the submitter, when an agent replies. Internal notes are
    excluded at the call site, which already distinguishes them — the entire point of
    an internal note is that the submitter does not see it.

  ticketing.assigned → the agent a ticket is handed to. The previous assignee is
    captured BEFORE the write so a real hand-off is distinguishable from a save that
    merely touched other fields, and assigning to yourself sends nothing.

Neither is mandatory: a reply notice is a convenience, not a security or financial
record, so it is opt-out in a person's preferences.

Bridge shape copied from Dashboard\Tools\Support\ToolsNotifier: one class owning the
platform contact, available() false when the platform is absent or dark, and a queue
failure logged rather than thrown — sending a reply must succeed even when the email
cannot be queued. DELIBERATELY NOT a manifest dependency, for the documented reason:
requires.apps is a HARD requirement and Notifications is still beta and ships dark.

There is no direct-mail fallback, unlike the swept apps, because there was never any
direct mail to fall back to. With Notifications dark, ticketing behaves exactly as it
does today: silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 12:58:24 -04:00
1175c20a57 Ticketing groups can be retired (e2e dashboard-ticketing#7)
There was no destroy route at all — only settings.groups.store and .update — so
the e2e CRUD spec, which creates an ephemeral group as its documented setup step,
could never clean up (a DELETE-spoofed POST returned 405). Every sweep left litter
behind, and an operator who mistyped a group was stuck with it forever.

Deleting a group cannot be unconditional: tickets are the audit record of real
correspondence, and tickets.group_id is a RESTRICT FK. So destroyGroup counts the
tickets and picks:

  0 tickets  -> real delete (agent access / projects / email connections are
                cascadeOnDelete, so nothing dangles). This is the teardown and
                undo-a-typo path.
  >=1 ticket -> archived_at = now(). Tickets untouched and still readable.

The caller is TOLD which happened rather than inferring it from a 302: an
expectsJson caller gets {"outcome":"deleted"|"archived","ticket_count":N}, the UI
gets distinct flash wording, and the confirm modal says which it will be BEFORE
you click ("This group has 1 ticket and will be archived, not deleted").
Archiving is idempotent; restore is a separate route.

Archived groups leave the PICKERS but nothing else: index() and create() use
scopeActive, store() validation adds whereNull('archived_at') so a stale id cannot
bypass the picker, and update() rejects transfers INTO an archived group while
always allowing the ticket's own group — otherwise a ticket stranded in an
archived group would become uneditable. Index.vue re-adds that own group to the
transfer select labelled "(archived)" so the select is never blank. Deliberately
NOT filtered: the Settings groups list (archived rows show a badge + Restore), and
agentGroupIds()/isAgent()/isManager(), which read ticketing_agent_access — so
existing agents keep working the tickets that remain. No global scope; exclusion
is per call site.

Gating is byte-identical to updateGroup: inside the existing
permission:ticketing.settings group plus requireManagerAccess(), so a super admin
with no manager row on that group is still refused (asserted).

Migration is additive — one nullable timestamp. down() is a deliberate NO-OP,
matching the myedpuller-sync warnings-column precedent: DashboardDoctor greps the
whole file for drop/rename patterns, so a dropColumn even in down() would raise a
"pending destructive migration" advisory on every host where it is still pending,
which is a confusing warning for a purely additive change. Verified against
doctor's exact regex, not a looser grep.

Tests: 9 (69 assertions). 10 mutations, all 10 detected — including forcing a hard
delete on a group with tickets, which throws a QueryException from the RESTRICT FK,
i.e. the database backs up the application check.

Browser-verified on dev: the confirm modal wording for both cases, the ARCHIVED
badge and Restore, the archived group vanishing from both the create picker and the
All-Tickets switcher while its ticket stays listed, and ZERO native
confirm/alert dialogs. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 19:35:04 -04:00
9ce5b45b79 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>
2026-07-26 19:05:30 -04:00
0be06694b2 Read the attachment disk from config, not env (secrets S3)
The three reads were `config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'))`,
but this package had no config file at all — so the config() lookup was always
null and env() was the only reader.

Once `config:cache` runs in production, a runtime env() returns NULL. Every
ticket attachment would have silently gone to the 'local' disk, ignoring
whatever the operator had configured — and previously-stored attachments would
404 because show() looked on the wrong disk.

Verified rather than assumed: with TICKETING_STORAGE_DISK=s3proof set,
config() returned 's3proof' both cached and uncached, while env() returned
's3proof' uncached and NULL cached.

Adds config/ticketing.php + mergeConfigFrom so the value is captured at
cache time, and drops the env() fallbacks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:00:15 -04:00
Claude
a54c01453d Declare tier/stability in the manifest
Ship-dark and the dashboard:doctor deploy gate only protect apps that DECLARE
what they are; this app was falling through to defaults. Pure metadata, additive
only, no version bump (repo convention: manifest-only changes do not bump).

bosch is BETA deliberately: production last deployed 2026-07-16 and this app was
built on the 19th, so the next prod deploy is its FIRST install — it talks to a
real alarm panel and its supervised cutover day has not happened. Ship-dark means
it lands disabled until an operator turns it on. signinout is beta for the same
reason (planned, scaffold only). Everything long-live in prod is stable, because
wrongly tagging a live app beta could dark it, while stable never disables
anything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:26:05 -04:00
dcfbf20ab0 UI: full-width pages (shell standard 2026-07-24)
Page wrappers drop their max-w cap in line with the shell's style guide: the
page uses the whole content area. Single-column create/edit forms keep a capped
column (w-full max-w-2xl) so their inputs stay readable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 19:42:55 -04:00
17 changed files with 462 additions and 2017 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/ticketing",
"description": "Help desk ticketing snap-in for the Dashboard platform",
"version": "1.0.1",
"version": "1.0.4",
"type": "library",
"require": {
"php": "^8.2",
@@ -19,6 +19,8 @@
"providers": ["Dashboard\\Ticketing\\TicketingServiceProvider"]
},
"dashboard": {
"tier": "optional",
"stability": "beta",
"nav_folder": {
"label": "Help Desk",
"icon": "ticket",

15
config/ticketing.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
return [
/*
* The filesystem disk ticket attachments are stored on. Read at runtime via
* config('ticketing.storage_disk') — NOT env() — so it survives config:cache
* in production. Before this file existed the controllers read
* env('TICKETING_STORAGE_DISK') directly, which returns null once the config
* is cached, silently forcing every attachment onto the 'local' disk and
* orphaning anything an operator had pointed elsewhere (secrets spec S3).
*/
'storage_disk' => env('TICKETING_STORAGE_DISK', 'local'),
];

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Groups can be retired without destroying ticket history.
*
* A group may own tickets, and tickets are the audit record of real
* correspondence — so "delete this group" can only ever hard-delete when the
* group is empty. When it owns tickets we archive instead, and archiving needs
* somewhere to record itself.
*
* Expand-only: one nullable timestamp added to an existing table. Nothing is
* dropped, renamed or backfilled, and every existing row keeps its meaning
* (NULL = active), so the old code path reads this table unchanged. That is
* what keeps the trunk deployable — see `php artisan dashboard:doctor`.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('ticketing_groups', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('prefix');
});
}
public function down(): void
{
// Deliberate NO-OP, matching the warnings-column precedent in
// dashboard-myedpuller-sync: DashboardDoctor greps the WHOLE migration file
// for drop/rename patterns, so a dropColumn even in down() raises the
// "pending destructive migration" advisory on every host where this is
// still pending — a confusing warning for a purely additive change. A
// nullable column costs nothing to leave behind.
}
};

View File

@@ -1,131 +0,0 @@
<template>
<AppLayout>
<div class="max-w-2xl mx-auto">
<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="max-w-2xl mx-auto">
<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="max-w-3xl mx-auto">
<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="max-w-4xl mx-auto">
<!-- 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>

View File

@@ -49,7 +49,7 @@ class TicketAttachmentController extends Controller
]);
$file = $request->file('file');
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
$disk = config('ticketing.storage_disk', 'local');
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
TicketAttachment::create([
@@ -71,7 +71,7 @@ class TicketAttachmentController extends Controller
abort(403);
}
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
$disk = config('ticketing.storage_disk', 'local');
return Storage::disk($disk)->download($attachment->path, $attachment->filename);
}
}

View File

@@ -17,6 +17,7 @@ use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
@@ -172,7 +173,14 @@ class TicketController extends Controller
return $ticket;
});
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
// Picker data: the group switcher/filter and the detail pane's transfer
// select both read this, so archived groups are excluded. The tickets
// themselves are NOT filtered — an archived group's tickets stay in the
// list and stay workable; Index.vue re-adds the current group to the
// transfer options when it happens to be archived.
$groups = TicketingGroup::active()
->when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))
->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
->where('status', 'active')
@@ -269,7 +277,10 @@ class TicketController extends Controller
$agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0;
$groups = TicketingGroup::when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))->get();
// New tickets can only be filed against a live group.
$groups = TicketingGroup::active()
->when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))
->get();
// No groups at all? Show first-run state.
if ($groups->isEmpty() && TicketingGroup::count() === 0) {
@@ -296,8 +307,18 @@ class TicketController extends Controller
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'required|string',
'group_id' => 'required|exists:ticketing_groups,id',
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
// Archived groups are gone from the picker; they must also be
// unreachable by posting a stale id.
//
// `integer` before `exists` is load-bearing, not tidiness: `exists`
// given an ARRAY checks that every element exists, so `group_id: [14]`
// sailed through with one valid id and reached the controller as an
// array. findOrFail() then returned a Collection, ->nextTicketNumber()
// on it fatalled, and the endpoint 500'd instead of 422ing. Only some
// array shapes showed it — `['a','b']` was rejected by exists itself,
// which is why this hid behind a passing validation rule.
'group_id' => ['required', 'integer', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')],
'priority_id' => 'nullable|integer|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'attachments' => 'nullable|array|max:5',
'attachments.*' => 'file|max:20480',
@@ -322,7 +343,7 @@ class TicketController extends Controller
]);
if ($request->hasFile('attachments')) {
$disk = config('ticketing.storage_disk', env('TICKETING_STORAGE_DISK', 'local'));
$disk = config('ticketing.storage_disk', 'local');
foreach ($request->file('attachments') as $file) {
if (!$file->isValid()) continue;
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
@@ -439,10 +460,23 @@ class TicketController extends Controller
'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string',
'status' => "sometimes|in:{$validStatusSlugs}",
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
// `integer` guards the same array-through-`exists` hole as store().
'priority_id' => 'nullable|integer|exists:ticketing_priority_levels,id',
'due_date' => 'nullable|date',
'project_id' => 'nullable|exists:ticketing_projects,id',
'group_id' => 'nullable|exists:ticketing_groups,id',
'project_id' => 'nullable|integer|exists:ticketing_projects,id',
// A ticket may not be transferred INTO an archived group, but a
// ticket already sitting in one must still be editable — so its own
// group id always validates.
'group_id' => ['nullable', 'integer', 'exists:ticketing_groups,id',
function ($attribute, $value, $fail) use ($ticket) {
if (! $value || (int) $value === (int) $ticket->group_id) {
return;
}
if (TicketingGroup::whereKey($value)->archived()->exists()) {
$fail('That group is archived — choose an active group.');
}
},
],
'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]),
];
@@ -468,8 +502,33 @@ class TicketController extends Controller
}
}
// Issue #8: capture the previous assignee BEFORE the write, so a genuine
// hand-off can be told from a save that merely touched other fields.
$previousAssignee = $ticket->assigned_to;
$ticket->update($validated);
$newAssignee = $ticket->assigned_to;
$handedOver = $newAssignee
&& $newAssignee !== $previousAssignee
&& $newAssignee !== Auth::id(); // assigning to yourself needs no telling
if ($handedOver) {
$agent = \App\Models\User::find($newAssignee);
$email = trim((string) ($agent?->email ?? ''));
if ($email !== '') {
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
\Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED,
$email,
"[{$ticket->number}] Assigned to you: {$ticket->title}",
'<p>'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.</p>'
.'<p><a href="'.e(route('ticketing.show', $ticket->id)).'">View the ticket</a></p>',
$ticket,
);
}
}
return back()->with('success', 'Ticket updated.');
}

View File

@@ -62,9 +62,34 @@ class TicketMessageController extends Controller
return back()->with('success', 'Message sent.');
}
/**
* Issue #8: this was an empty placeholder, so nobody has ever been told their
* ticket was answered. Goes through the Notifications platform so it lands in the
* shared Notification Log and honours the person's preferences, rather than
* reaching for Mail:: directly as the old comment suggested.
*
* Only ever called for a non-internal agent reply — see the caller.
*/
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
{
// Placeholder for email notification
// In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message));
$submitter = \App\Models\User::find($ticket->submitter_id);
$email = trim((string) ($submitter?->email ?? ''));
if ($email === '') {
return; // a ticket raised on behalf of somebody with no address
}
$url = route('ticketing.show', $ticket->id);
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
$email,
"[{$ticket->number}] {$ticket->title}",
'<p>'.e($author).' replied to your ticket:</p>'
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
.'<p><a href="'.e($url).'">View the ticket</a></p>',
$ticket,
);
}
}

View File

@@ -72,9 +72,18 @@ class TicketingSettingsController extends Controller
$isBootstrap = $this->isBootstrapState();
$myGroupIds = TicketingAgentAccess::where('user_id', $userId)->pluck('group_id');
// Archived groups stay in this list — this is the one place they must
// remain visible, so a manager can see what happened and restore it.
// Actives first, then archived, each alphabetical.
$groups = $isBootstrap
? collect()
: TicketingGroup::whereIn('id', $myGroupIds)->get();
: TicketingGroup::whereIn('id', $myGroupIds)
// tickets_count decides archive-vs-delete, so the UI can warn
// the operator which one their click is about to do.
->withCount('tickets')
->orderByRaw('archived_at IS NOT NULL')
->orderBy('name')
->get();
$agents = $isBootstrap
? collect()
@@ -180,13 +189,113 @@ class TicketingSettingsController extends Controller
return back()->with('success', 'Group updated.');
}
/**
* Retire a group — hard delete when it is empty, archive when it is not.
*
* Tickets are the audit record of real correspondence, so a group that owns
* any ticket can never be deleted: `tickets.group_id` is a RESTRICT foreign
* key, which is the backstop, and this method is the polite front door. It
* archives instead, leaving every ticket byte-identical and readable.
*
* An empty group has nothing to protect, so it really is deleted — which is
* what lets an operator undo a typo and lets the e2e CRUD spec clean up the
* throwaway group it creates as its setup step (issue #7).
*
* The caller is told which of the two happened, explicitly: an `outcome` of
* `deleted` or `archived` in the JSON body for API callers, and distinct
* flash wording for the Inertia UI. Nobody has to guess from a 302.
*/
public function destroyGroup(Request $request, TicketingGroup $group)
{
$this->requireManagerAccess($group->id);
$ticketCount = $group->tickets()->count();
if ($ticketCount > 0) {
// Idempotent: archiving an already-archived group is not an error,
// it just re-reports the same outcome.
if ($group->archived_at === null) {
$group->archived_at = now();
$group->save();
}
return $this->groupRetirementResponse($request, 'archived', $group, $ticketCount);
}
// Empty group: agent access, projects and email connections all cascade
// on delete (see their migrations), so nothing is left dangling.
$group->delete();
return $this->groupRetirementResponse($request, 'deleted', $group, 0);
}
/**
* Un-archive a group so it can take new tickets again.
*/
public function restoreGroup(Request $request, TicketingGroup $group)
{
$this->requireManagerAccess($group->id);
if ($group->archived_at !== null) {
$group->archived_at = null;
$group->save();
}
if ($this->wantsJsonBody($request)) {
return response()->json([
'outcome' => 'restored',
'group_id' => $group->id,
]);
}
return back()->with('success', "Group “{$group->name}” restored.");
}
/**
* One shape for "what happened to that group", for both kinds of caller.
*/
private function groupRetirementResponse(Request $request, string $outcome, TicketingGroup $group, int $ticketCount)
{
if ($this->wantsJsonBody($request)) {
return response()->json([
'outcome' => $outcome,
'group_id' => $group->id,
'ticket_count' => $ticketCount,
]);
}
$message = $outcome === 'deleted'
? "Group “{$group->name}” deleted — it had no tickets."
: "Group “{$group->name}” archived — it has {$ticketCount} ticket"
. ($ticketCount === 1 ? '' : 's') . ', which stay readable.';
return back()->with('success', $message);
}
/**
* True for an API/script caller that wants a JSON body.
*
* Inertia sends `Accept: text/html` but also XHR headers, so
* expectsJson() alone would hijack the UI's redirect-back flow.
*/
private function wantsJsonBody(Request $request): bool
{
if ($request->header('X-Inertia')) {
return false;
}
return $request->expectsJson();
}
public function storeAgent(Request $request)
{
$this->requireAgentAccess();
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'group_id' => 'required|exists:ticketing_groups,id',
'user_id' => 'required|integer|exists:users,id',
// `integer` before `exists`: an array value passes `exists` element-wise,
// so [14] would reach the code below as an array. See TicketController::store().
'group_id' => 'required|integer|exists:ticketing_groups,id',
'role' => 'required|in:agent,manager',
]);
@@ -267,7 +376,9 @@ class TicketingSettingsController extends Controller
$this->requireAgentAccess();
$validated = $request->validate([
'group_id' => 'required|exists:ticketing_groups,id',
// `integer` before `exists`: an array value passes `exists` element-wise,
// so [14] would reach the code below as an array. See TicketController::store().
'group_id' => 'required|integer|exists:ticketing_groups,id',
'name' => 'required|string|max:100',
'description' => 'nullable|string',
'status' => 'required|in:active,archived',
@@ -320,7 +431,9 @@ class TicketingSettingsController extends Controller
$this->requireAgentAccess();
$validated = $request->validate([
'group_id' => 'required|exists:ticketing_groups,id',
// `integer` before `exists`: an array value passes `exists` element-wise,
// so [14] would reach the code below as an array. See TicketController::store().
'group_id' => 'required|integer|exists:ticketing_groups,id',
'type' => 'required|in:gmail,imap',
'active' => 'boolean',
'config.host' => 'nullable|string|max:255',

View File

@@ -2,6 +2,7 @@
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -9,6 +10,36 @@ class TicketingGroup extends Model
{
protected $fillable = ['name', 'email_address', 'color', 'prefix'];
protected $casts = [
'archived_at' => 'datetime',
];
protected $appends = ['is_archived'];
/**
* Archived groups are deliberately NOT hidden by a global scope.
*
* The tickets in an archived group are still live correspondence: their
* agents must keep working them, `$ticket->group` must still resolve, and
* Settings must still list the group so a manager can restore it. Only the
* *pickers* — "which group should this new ticket go to", "filter by
* group" — exclude archived ones, via this scope, one call site at a time.
*/
public function scopeActive(Builder $query): Builder
{
return $query->whereNull('archived_at');
}
public function scopeArchived(Builder $query): Builder
{
return $query->whereNotNull('archived_at');
}
public function getIsArchivedAttribute(): bool
{
return $this->archived_at !== null;
}
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'group_id');

View File

@@ -0,0 +1,134 @@
<?php
namespace Dashboard\Ticketing\Support;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
/**
* The ONE place Ticketing reaches into the Notifications platform app (bridge
* shape: Dashboard\Tools\Support\ToolsNotifier).
*
* Issue #8: ticketing had NO notification integration at all — no `ticketing.*`
* category existed, so the shared Notification Log listed a category for every
* other app and nothing for this one. Note this was not a mail sweep like the
* other apps: there was no existing Mail:: call to redirect.
* TicketMessageController::notifySubmitter() was an empty stub with the comment
* "In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…))", so
* nobody has ever been told their ticket was answered.
*
* TWO CATEGORIES, matching the two moments a person is waiting on somebody else:
*
* - ticketing.replied → the submitter, when an agent answers. Internal notes are
* excluded by the caller: the whole point of an internal note is that the
* submitter does not see it.
* - ticketing.assigned → the agent a ticket has just been handed to. Skipped when
* somebody assigns a ticket to themselves, which needs no telling.
*
* Neither is mandatory. A reply notice is a convenience, not a security or
* financial record, so a person may turn it off in their notification preferences.
*
* SHIP-DARK SAFETY. available() is false when the platform is absent, and every
* caller treats a false return as "nothing was sent" rather than an error — there is
* no direct-mail fallback to run, because there was never any direct mail here.
* DELIBERATELY NOT a manifest dependency: requires.apps is a HARD requirement (a
* merely-disabled dep fails dashboard:doctor, which gates update.sh and promote.sh),
* and Notifications is a beta app that ships dark. Add it once Notifications is
* stability: stable.
*/
final class TicketingNotifier
{
public const REPLIED = 'ticketing.replied';
public const ASSIGNED = 'ticketing.assigned';
/** @var array<string, array{0:string,1:string}> key => [label, description] */
public const CATEGORIES = [
self::REPLIED => [
'Your help desk ticket was answered',
'Sent to the person who raised a ticket when an agent replies. Internal notes never trigger this.',
['staff', 'student'],
],
self::ASSIGNED => [
'A help desk ticket was assigned to you',
'Sent to an agent when a ticket is assigned to them by somebody else.',
['staff'],
],
];
/** Test hook — behave as though Notifications isn't installed. */
private static bool $pretendMissing = false;
public static function available(): bool
{
return ! self::$pretendMissing
&& class_exists(\Central\Notifications\Support\Notify::class)
&& class_exists(\Central\Notifications\Support\Message::class);
}
public static function pretendPlatformMissing(bool $missing = true): void
{
self::$pretendMissing = $missing;
}
/** Declare the categories. Called from TicketingServiceProvider::boot(). */
public static function registerCategories(): void
{
if (! class_exists(\Central\Notifications\Support\NotificationRegistry::class)) {
return;
}
try {
foreach (self::CATEGORIES as $key => [$label, $description, $audience]) {
\Central\Notifications\Support\NotificationRegistry::register(
$key, $label, $description, ['mail'], mandatory: false, audience: $audience,
);
}
} catch (\Throwable $e) {
Log::warning('Ticketing: could not register notification categories: '.$e->getMessage());
}
}
/**
* Queue one email through the platform.
*
* @param string $to recipient address, verbatim
* @return array{handled:bool,status:?string,error:?string,id:?int}
*/
public static function deliver(
string $category,
string $to,
string $subject,
string $bodyHtml,
?Model $context = null,
): array {
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
if (! self::available() || trim($to) === '') {
return $unhandled;
}
try {
$row = \Central\Notifications\Support\Notify::send(new \Central\Notifications\Support\Message(
category: $category,
to: $to,
subject: $subject,
body: $bodyHtml,
context: $context,
));
} catch (\Throwable $e) {
// Never let a notification failure break the action that caused it:
// sending a reply must succeed even if the email cannot be queued.
Log::warning("Ticketing: notification '{$category}' could not be queued: ".$e->getMessage());
return $unhandled;
}
return [
'handled' => true,
'status' => $row->status,
'error' => $row->status === 'failed' ? ($row->error ?: 'The notification could not be queued.') : null,
'id' => $row->id,
];
}
}

View File

@@ -6,13 +6,23 @@ use Illuminate\Support\ServiceProvider;
class TicketingServiceProvider extends ServiceProvider
{
public function register(): void {}
public function register(): void
{
// Merge so config('ticketing.*') resolves even when the host app has not
// published a copy — and, crucially, so the value is captured by
// config:cache instead of being read from env() at runtime (S3).
$this->mergeConfigFrom(__DIR__.'/../config/ticketing.php', 'ticketing');
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
// Declare ticketing.* notification categories (issue #8). Safe to call when
// Notifications is absent or dark — the notifier no-ops.
\Dashboard\Ticketing\Support\TicketingNotifier::registerCategories();
if ($this->app->runningInConsole()) {
$this->commands([
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,

View File

@@ -13,8 +13,20 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
// ── Fixed paths first (before any /{ticket} wildcards) ────────────────
// my-tickets and prefs carried only `app.access:ticketing`, which means "you hold
// ANY ticketing.* permission" — so ticketing.settings alone opened them. They are
// the submitter's own surface, and ticketing.view is literally described as
// "Submit tickets and view own ticket history", so it is the right key; managers
// hold it implicitly via the same either-or the rest of this file uses.
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
Route::get('/my-tickets', [TicketController::class, 'myTickets'])->name('my-tickets');
Route::post('/prefs', [TicketController::class, 'savePrefs']) ->name('prefs.save');
});
// Attachments stay ungated ON PURPOSE: TicketAttachmentController::show already
// authorises submitter-or-agent per attachment, which is finer than any
// permission could be. A key here would either duplicate that check or
// contradict it.
Route::get('/attachments/{attachment}', [TicketAttachmentController::class, 'show'])->name('attachments.show');
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {
@@ -33,6 +45,8 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
->where('tab', 'groups|agents|priorities|statuses|projects|automation')->name('settings.tab');
Route::post('/settings/groups', [TicketingSettingsController::class, 'storeGroup']) ->name('settings.groups.store');
Route::put('/settings/groups/{group}', [TicketingSettingsController::class, 'updateGroup']) ->name('settings.groups.update');
Route::delete('/settings/groups/{group}', [TicketingSettingsController::class, 'destroyGroup']) ->name('settings.groups.destroy');
Route::post('/settings/groups/{group}/restore', [TicketingSettingsController::class, 'restoreGroup']) ->name('settings.groups.restore');
Route::post('/settings/agents', [TicketingSettingsController::class, 'storeAgent']) ->name('settings.agents.store');
Route::delete('/settings/agents/{access}', [TicketingSettingsController::class, 'destroyAgent']) ->name('settings.agents.destroy');
Route::post('/settings/priorities', [TicketingSettingsController::class, 'storePriority']) ->name('settings.priorities.store');