13 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
e49a1e2ac9 Ticketing: settings tabs are deep-linkable routes (/settings/{tab})
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh8RnYXrC8E6z79LWs8ggd
2026-06-21 20:19:04 -04:00
Joel Wedemire
dd0a458250 feat: ticket views, statuses, participants, merge; mobile layout fixes (#5)
- New migrations: ticket views, statuses, participants, merge support
- New models: TicketView, TicketStatus, TicketParticipant
- New seeder: EmailTemplatesSeeder
- Console commands for ticketing
- Mobile: sidebar min-w-0/overflow-hidden, tab nav overflow-x-auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:22:45 -07:00
Joel Wedemire
ffb64078d8 fix(mobile): add min-w-0/overflow-hidden to sidebar panes, truncate header text
Closes dashboard-ticketing #5: sidebar and group switcher overflowed on 375px viewport.
Adds min-w-0 to aside and inner content div, truncates header labels. Settings tab nav
already had overflow-x-auto with shrink-0 tabs; no additional changes needed there.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:12:49 -07:00
29 changed files with 1861 additions and 2274 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/ticketing",
"description": "Help desk ticketing snap-in for the Dashboard platform",
"version": "1.0.0",
"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",
@@ -34,6 +36,9 @@
{ "key": "ticketing.create", "label": "Create Tickets", "description": "Create tickets on behalf of other users" },
{ "key": "ticketing.manage", "label": "Manage Tickets", "description": "View, assign, and resolve all tickets" },
{ "key": "ticketing.settings", "label": "Manage Settings", "description": "Configure groups, priorities, and integrations" }
],
"seeders": [
"Dashboard\\Ticketing\\Database\\Seeders\\EmailTemplatesSeeder"
]
}
}

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,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Deduplicate before removing group_id:
// If the same priority name exists multiple times (once per group), keep the first
// and delete duplicates, then reassign any tickets that referenced the deleted ones.
$names = DB::table('ticketing_priority_levels')
->select('name')
->groupBy('name')
->havingRaw('COUNT(*) > 1')
->pluck('name');
foreach ($names as $name) {
$rows = DB::table('ticketing_priority_levels')
->where('name', $name)
->orderBy('id')
->get();
$keepId = $rows->first()->id;
foreach ($rows->skip(1) as $row) {
// Reassign tickets pointing at the duplicate
DB::table('tickets')
->where('priority_id', $row->id)
->update(['priority_id' => $keepId]);
DB::table('ticketing_priority_levels')->where('id', $row->id)->delete();
}
}
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
// Drop the foreign key and column
$table->dropForeign(['group_id']);
$table->dropColumn('group_id');
});
}
public function down(): void
{
Schema::table('ticketing_priority_levels', function (Blueprint $table) {
$table->foreignId('group_id')->nullable()->constrained('ticketing_groups')->nullOnDelete();
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_views', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete();
$table->unsignedBigInteger('user_id');
$table->timestamp('viewed_at');
$table->index(['ticket_id', 'user_id']);
$table->index('viewed_at');
});
}
public function down(): void
{
Schema::dropIfExists('ticket_views');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Create ticket_participants table
Schema::create('ticket_participants', function (Blueprint $table) {
$table->id();
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('user_id');
$table->unsignedBigInteger('added_by')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->unique(['ticket_id', 'user_id']);
});
// 2. Add merged_into_id to tickets
Schema::table('tickets', function (Blueprint $table) {
$table->unsignedBigInteger('merged_into_id')->nullable()->after('due_date');
$table->foreign('merged_into_id')->references('id')->on('tickets')->nullOnDelete();
});
// 3. Expand status ENUM to include 'merged'
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
}
public function down(): void
{
// Reverse ENUM change (remove merged)
DB::statement("UPDATE tickets SET status = 'closed' WHERE status = 'merged'");
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed') DEFAULT 'open'");
Schema::table('tickets', function (Blueprint $table) {
$table->dropForeign(['merged_into_id']);
$table->dropColumn('merged_into_id');
});
Schema::dropIfExists('ticket_participants');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 1. Create ticketing_statuses table
Schema::create('ticketing_statuses', function (Blueprint $table) {
$table->id();
$table->string('slug', 50)->unique(); // machine key — immutable after creation
$table->string('name', 100); // user-facing display name
$table->string('color', 7)->default('#6b7280'); // hex color
$table->unsignedSmallInteger('sort_order')->default(0);
$table->boolean('is_closed')->default(false); // counts as "resolved/closed" in filters
$table->boolean('is_system')->default(false); // cannot be deleted
$table->timestamps();
});
// 2. Seed default statuses
$now = now();
DB::table('ticketing_statuses')->insert([
['slug' => 'open', 'name' => 'Open', 'color' => '#3b82f6', 'sort_order' => 1, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'in_progress', 'name' => 'In Progress', 'color' => '#7c3aed', 'sort_order' => 2, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'pending', 'name' => 'Pending', 'color' => '#d97706', 'sort_order' => 3, 'is_closed' => false, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'resolved', 'name' => 'Resolved', 'color' => '#16a34a', 'sort_order' => 4, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'closed', 'name' => 'Closed', 'color' => '#6b7280', 'sort_order' => 5, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
['slug' => 'merged', 'name' => 'Merged', 'color' => '#9ca3af', 'sort_order' => 99, 'is_closed' => true, 'is_system' => true, 'created_at' => $now, 'updated_at' => $now],
]);
// 3. Change tickets.status from ENUM to VARCHAR(50) — existing slug values are kept as-is
DB::statement("ALTER TABLE tickets MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'open'");
}
public function down(): void
{
// Restore ENUM (data already uses these slugs so no data loss)
DB::statement("ALTER TABLE tickets MODIFY COLUMN status ENUM('open','in_progress','pending','resolved','closed','merged') DEFAULT 'open'");
Schema::dropIfExists('ticketing_statuses');
}
};

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 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="w-full shrink-0 border-r border-gray-200 bg-white/90 backdrop-blur lg:flex lg:w-72 lg:flex-col"
: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>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-gray-400">Shared inbox</p>
<h1 class="mt-1 text-2xl font-semibold tracking-tight">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 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 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">{{ ticket.priority?.name || 'No priority' }}</div>
<div class="flex items-center gap-2">
<span class="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 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 items-start gap-3">
<h3 class="min-w-0 flex-1 text-2xl font-semibold tracking-tight text-gray-900">{{ ticketDetail.title }}</h3>
<Link :href="route('ticketing.show', { ticket: ticketDetail.id })" class="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-3 text-sm text-gray-500">
<span>Group: <strong class="text-gray-700">{{ ticketDetail.group?.name || 'Unknown' }}</strong></span>
<span>•</span>
<span>{{ ticketDetail.messages?.length || 0 }} messages</span>
<span>•</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 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-2xl font-bold text-gray-900 mt-2">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">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
:class="[
'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 justify-between items-center 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-2 gap-3">
<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-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-2 gap-3">
<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-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 justify-between items-center 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-3 gap-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-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 justify-between items-center 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-2 gap-3">
<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-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-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-2 gap-3">
<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-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-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 justify-between items-center 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-2 gap-3">
<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-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-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-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-2 gap-3">
<div class="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-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-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

@@ -0,0 +1,33 @@
<?php
namespace Dashboard\Ticketing\Console\Commands;
use App\Models\Setting;
use Dashboard\Ticketing\Models\Ticket;
use Illuminate\Console\Command;
class AutoCloseTickets extends Command
{
protected $signature = 'ticketing:auto-close';
protected $description = 'Close resolved tickets that have not been updated within the configured grace period.';
public function handle(): int
{
$days = (int) Setting::get('ticketing.auto_close_days', 0);
if ($days <= 0) {
$this->info('Auto-close is disabled (ticketing.auto_close_days = 0).');
return 0;
}
$cutoff = now()->subDays($days);
$count = Ticket::where('status', 'resolved')
->where('updated_at', '<=', $cutoff)
->update(['status' => 'closed']);
$this->info("Auto-closed {$count} ticket(s) resolved more than {$days} day(s) ago.");
return 0;
}
}

View File

@@ -0,0 +1,372 @@
<?php
namespace Dashboard\Ticketing\Database\Seeders;
use Illuminate\Support\Facades\DB;
/**
* Seeds all standard ticketing email templates into the shell's email_templates table.
* Uses updateOrCreate on slug so re-running on reinstall is safe — existing customisations
* to subject/body are NOT overwritten (only missing templates are inserted).
*/
class EmailTemplatesSeeder
{
public function run(): void
{
foreach ($this->templates() as $template) {
// Only insert if the slug doesn't exist yet — don't overwrite user edits
$exists = DB::table('email_templates')->where('slug', $template['slug'])->exists();
if (! $exists) {
DB::table('email_templates')->insert(array_merge($template, [
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]));
}
}
}
private function templates(): array
{
return [
// ── Submitter-facing ─────────────────────────────────────────────────
[
'slug' => 'ticketing.ticket_received',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Received — Confirmation',
'subject' => '[{{ticket_number}}] We received your request: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
Thank you for reaching out. We've received your request and created a ticket for you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Team: {{group_name}}
You can view your ticket and any updates here:
{{ticket_url}}
We'll be in touch as soon as possible.
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_closed',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Closed — Resolution Notice',
'subject' => '[{{ticket_number}}] Your ticket has been resolved',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been marked as resolved.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Team: {{group_name}}
{{resolution_note}}
If you have further questions or the issue has not been fully resolved, you can reply to this email or reopen your ticket here:
{{ticket_url}}
Thank you,
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.status_changed',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Status Changed — Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket status changed to {{new_status}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
The status of your ticket has been updated.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Previous status: {{old_status}}
New status: {{new_status}}
View your ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_reopened_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Reopened — Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket has been reopened',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been reopened and is being looked into again.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
View your ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.agent_reply',
'snap_in_slug' => 'ticketing',
'name' => 'Agent Reply to Submitter',
'subject' => '[{{ticket_number}}] Update on your ticket: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{submitter_name}},
{{agent_name}} has replied to your ticket:
---
{{reply_body}}
---
View and reply to your ticket here:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.pending_waiting',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Pending — Waiting on Submitter',
'subject' => '[{{ticket_number}}] We need more information from you',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket is currently on hold while we wait for additional information from you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Please reply with the requested information so we can continue helping you:
{{ticket_url}}
If we don't hear back within a few days, this ticket may be closed automatically.
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.priority_changed_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Priority Changed — Submitter Notice',
'subject' => '[{{ticket_number}}] Priority updated on your ticket',
'body' => <<<'BODY'
Hi {{submitter_name}},
The priority of your ticket has been updated.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Previous priority: {{old_priority}}
New priority: {{new_priority}}
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_escalated_submitter',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Escalated — Submitter Notice',
'subject' => '[{{ticket_number}}] Your ticket has been escalated',
'body' => <<<'BODY'
Hi {{submitter_name}},
Your ticket has been escalated to ensure a faster resolution.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
{{escalation_reason}}
We're treating this as a priority. You can track progress here:
{{ticket_url}}
{{app_name}} Support
BODY,
],
// ── Agent / internal notifications ───────────────────────────────────
[
'slug' => 'ticketing.ticket_assigned_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Assigned to Agent',
'subject' => '[{{ticket_number}}] New ticket assigned to you: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
A ticket has been assigned to you.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}} ({{submitter_email}})
Team: {{group_name}}
Priority: {{priority_name}}
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.submitter_reply_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Submitter Replied — Agent Notification',
'subject' => '[{{ticket_number}}] {{submitter_name}} replied: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
{{submitter_name}} has replied to their ticket.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
---
{{reply_body}}
---
View and respond:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.internal_note_agents',
'snap_in_slug' => 'ticketing',
'name' => 'Internal Note Posted — Agent Notification',
'subject' => '[{{ticket_number}}] New internal note by {{note_author}}',
'body' => <<<'BODY'
Hi,
{{note_author}} posted an internal note on ticket {{ticket_number}}.
Subject: {{ticket_subject}}
---
{{note_body}}
---
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_reopened_agent',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Reopened — Agent Notification',
'subject' => '[{{ticket_number}}] Ticket reopened: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
A ticket you worked on has been reopened.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}}
View ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.unassigned_alert',
'snap_in_slug' => 'ticketing',
'name' => 'Unassigned Ticket Alert — Manager Notification',
'subject' => '[{{ticket_number}}] Unassigned ticket needs attention: {{ticket_subject}}',
'body' => <<<'BODY'
Hi,
A ticket in {{group_name}} has not been assigned to an agent.
Ticket: {{ticket_number}}
Subject: {{ticket_subject}}
Submitted: {{submitted_at}}
Please assign this ticket:
{{ticket_url}}
{{app_name}} Support
BODY,
],
[
'slug' => 'ticketing.ticket_escalated_agents',
'snap_in_slug' => 'ticketing',
'name' => 'Ticket Escalated — Agent Notification',
'subject' => '[ESCALATED] [{{ticket_number}}] {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{agent_name}},
Ticket {{ticket_number}} has been escalated.
Subject: {{ticket_subject}}
Submitted by: {{submitter_name}} ({{submitter_email}})
Priority: {{priority_name}}
Reason:
{{escalation_reason}}
This ticket requires immediate attention:
{{ticket_url}}
{{app_name}} Support
BODY,
],
// ── Email-to-ticket auto-reply ────────────────────────────────────────
[
'slug' => 'ticketing.email_autoreply',
'snap_in_slug' => 'ticketing',
'name' => 'Email Inbound — Auto Reply',
'subject' => '[{{ticket_number}}] We received your message: {{ticket_subject}}',
'body' => <<<'BODY'
Hi {{sender_name}},
Thank you for your email. We've created a support ticket on your behalf.
Ticket: {{ticket_number}}
Team: {{group_name}}
You can track your request online here:
{{ticket_url}}
Please do not reply to this message — use the link above to add updates to your ticket.
{{app_name}} Support
BODY,
],
];
}
}

View File

@@ -17,15 +17,19 @@ class TicketingDemoSeeder extends Seeder
{
public function run(): void
{
$admin = DB::table('users')->where('email', 'admin@vancouverchristian.org')->first();
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
// Use the first super admin, or fall back to any existing user
$admin = DB::table('users')->where('is_super_admin', 1)->first()
?? DB::table('users')->first();
if (! $admin) {
$this->command?->warn('TicketingDemoSeeder skipped: admin user not found.');
$this->command?->warn('TicketingDemoSeeder skipped: no users found in the database.');
return;
}
// Additional agents (QA test accounts if they exist)
$micah = DB::table('users')->where('email', 'micah@qa.test')->first();
$nahum = DB::table('users')->where('email', 'nahum@qa.test')->first();
$users = collect([$admin, $micah, $nahum])->filter()->values();
// Clear prior demo/QA ticketing data so the demo state stays coherent instead of
@@ -59,20 +63,19 @@ class TicketingDemoSeeder extends Seeder
]);
}
// Global priorities — only seed once (first group iteration)
if (PriorityLevel::count() === 0) {
foreach ([
['name' => 'Low', 'color' => '#94a3b8', 'sort_order' => 1],
['name' => 'Medium', 'color' => '#3b82f6', 'sort_order' => 2],
['name' => 'High', 'color' => '#f59e0b', 'sort_order' => 3],
['name' => 'Urgent', 'color' => '#ef4444', 'sort_order' => 4],
] as $priorityData) {
PriorityLevel::firstOrCreate([
'group_id' => $group->id,
'name' => $priorityData['name'],
], [
'color' => $priorityData['color'],
'description' => null,
'sort_order' => $priorityData['sort_order'],
]);
PriorityLevel::firstOrCreate(
['name' => $priorityData['name']],
['color' => $priorityData['color'], 'description' => null, 'sort_order' => $priorityData['sort_order']]
);
}
}
$projects = match ($group->prefix) {
@@ -80,16 +83,19 @@ class TicketingDemoSeeder extends Seeder
['name' => 'Chromebook Repairs', 'description' => 'Student device triage and repairs'],
['name' => 'Staff Accounts', 'description' => 'Login, MFA, and permissions issues'],
['name' => 'Classroom AV', 'description' => 'Projectors, panels, and sound systems'],
['name' => 'Network & Wi-Fi', 'description' => 'Connectivity and access point issues'],
],
'FAC' => [
['name' => 'Work Orders', 'description' => 'General campus maintenance requests'],
['name' => 'HVAC', 'description' => 'Heating and cooling issues'],
['name' => 'Events Setup', 'description' => 'Room setup and teardown support'],
['name' => 'Grounds', 'description' => 'Exterior and landscaping requests'],
],
default => [
['name' => 'Onboarding', 'description' => 'New hire setup and paperwork'],
['name' => 'Benefits', 'description' => 'Benefits questions and follow-up'],
['name' => 'Policy Questions', 'description' => 'Staff handbook and policy clarifications'],
['name' => 'Payroll', 'description' => 'Pay queries and corrections'],
],
};
@@ -109,31 +115,30 @@ class TicketingDemoSeeder extends Seeder
$submitterPool = $this->ensureSubmitterPool();
$statuses = ['open', 'in_progress', 'pending', 'resolved', 'closed'];
$ticketBlueprints = $this->ticketBlueprints();
$blueprints = $this->ticketBlueprints();
$blueprintCount = count($blueprints);
$ticketCount = 45;
foreach (range(1, 50) as $i) {
foreach (range(1, $ticketCount) as $i) {
$group = $groupModels[($i - 1) % $groupModels->count()];
$priority = $group->priorityLevels->random();
$project = $group->projects->random();
$submitter = $submitterPool[($i - 1) % count($submitterPool)];
$assigneeId = $group->agentAccess->random()->user_id;
$blueprint = $ticketBlueprints[($i - 1) % count($ticketBlueprints)];
$createdAt = Carbon::now()->subDays(rand(0, 35))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
$blueprint = $blueprints[($i - 1) % $blueprintCount];
$createdAt = Carbon::now()->subDays(rand(0, 45))->subHours(rand(0, 23))->subMinutes(rand(0, 59));
$status = $statuses[array_rand($statuses)];
$title = $blueprint['title'];
if ($i > count($ticketBlueprints)) {
$title .= ' #' . $i;
}
$ticket = Ticket::updateOrCreate(
['number' => $group->prefix . '-' . str_pad((string) $i, 4, '0', STR_PAD_LEFT)],
[
'group_id' => $group->id,
'submitter_id' => $submitter['id'],
'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0 ? null : $assigneeId,
'assigned_to' => in_array($status, ['open', 'resolved', 'closed']) && rand(0, 1) === 0
? null
: $assigneeId,
'project_id' => $project->id,
'title' => $title,
'title' => $blueprint['title'],
'description' => $blueprint['description'],
'status' => $status,
'priority_id' => $priority->id,
@@ -154,14 +159,14 @@ class TicketingDemoSeeder extends Seeder
private function ensureSubmitterPool(): array
{
$defaults = [
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local', 'role' => 'staff'],
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local', 'role' => 'staff'],
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local', 'role' => 'staff'],
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local', 'role' => 'staff'],
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local', 'role' => 'admin'],
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local', 'role' => 'staff'],
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local', 'role' => 'staff'],
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local', 'role' => 'staff'],
['name' => 'Ava Teacher', 'email' => 'ava.teacher@vcs.local'],
['name' => 'Ben EA', 'email' => 'ben.ea@vcs.local'],
['name' => 'Chloe Office', 'email' => 'chloe.office@vcs.local'],
['name' => 'Daniel Coach', 'email' => 'daniel.coach@vcs.local'],
['name' => 'Emma Principal', 'email' => 'emma.principal@vcs.local'],
['name' => 'Finn Student Services', 'email' => 'finn.services@vcs.local'],
['name' => 'Grace Library', 'email' => 'grace.library@vcs.local'],
['name' => 'Hudson Counsellor', 'email' => 'hudson.counsellor@vcs.local'],
];
$pool = [];
@@ -174,7 +179,7 @@ class TicketingDemoSeeder extends Seeder
'name' => $person['name'],
'email' => $person['email'],
'google_id' => 'demo-' . Str::slug($person['email']),
'role' => $person['role'],
'is_super_admin' => 0,
'email_verified_at' => now(),
'password' => bcrypt(Str::random(32)),
'created_at' => now(),
@@ -226,6 +231,17 @@ class TicketingDemoSeeder extends Seeder
];
}
if (in_array($status, ['pending']) && $agentId) {
$messages[] = [
'user_id' => $submitter['id'],
'author_email' => $submitter['email'],
'body' => 'Thanks for looking into this. To answer your question — it started about a week ago and it does happen consistently.',
'is_internal' => false,
'source' => 'web',
'created_at' => $createdAt->copy()->addHours(rand(3, 20)),
];
}
if (in_array($status, ['resolved', 'closed'])) {
$messages[] = [
'user_id' => $submitter['id'],
@@ -266,37 +282,128 @@ class TicketingDemoSeeder extends Seeder
private function internalNoteForGroup(string $prefix): string
{
return match ($prefix) {
'IT' => 'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.',
'FAC' => 'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
'HR' => 'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
default => 'Internal: triage complete, waiting on next action.',
$notes = match ($prefix) {
'IT' => [
'Internal: likely permissions/device state issue. If no response by tomorrow, follow up and verify the user can reproduce on a second device.',
'Internal: checked MDM — device is enrolled. May need remote wipe if issue persists.',
'Internal: similar issue came up last month, possibly related to the Chrome policy push from 2 weeks ago.',
'Internal: this looks like a profile sync error. Tried clearing cache remotely, should be resolved.',
],
'FAC' => [
'Internal: bundle with nearby work orders if possible. Check whether this is part of a recurring room issue before assigning external contractor time.',
'Internal: parts are on order, ETA 3 business days. Keep submitter updated.',
'Internal: assigned to weekend crew, no disruption to classes expected.',
'Internal: this is the third report from this room — worth doing a full inspection.',
],
default => [
'Internal: keep response factual and short. Confirm whether there is a policy or payroll dependency before promising a turnaround.',
'Internal: escalated to payroll for confirmation. Waiting on their response before replying to the submitter.',
'Internal: reviewed file, looks like a data entry error on our end. Will correct and follow up.',
'Internal: HR director has been looped in. Response expected by end of week.',
],
};
return $notes[array_rand($notes)];
}
private function ticketBlueprints(): array
{
return [
['title' => 'Projector not turning on in Room 204', 'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'],
['title' => 'Need access to new staff shared drive', 'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'],
['title' => 'Chromebook cart missing three chargers', 'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
['title' => 'Gym thermostat is stuck at 27°C', 'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'],
['title' => 'Can’t print to office copier', 'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'],
['title' => 'New employee onboarding checklist incomplete', 'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'],
['title' => 'Classroom speakers crackling during assemblies', 'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
['title' => 'Request for additional key fob', 'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
['title' => 'Google account keeps asking for password again', 'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'],
['title' => 'Benefits question about dependent coverage', 'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
['title' => 'Broken desk in portable 3', 'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
['title' => 'Staff laptop camera not detected in Meet', 'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'],
['title' => 'Request setup for parent info night', 'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
['title' => 'Payroll question on missed stipend', 'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'],
['title' => 'Door closer slamming in west hallway', 'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
['title' => 'Need substitute teacher account reactivated', 'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
['title' => 'Student device filter blocking approved site', 'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'],
['title' => 'Request ergonomic chair assessment', 'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
['title' => 'Water fountain on second floor leaking', 'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
['title' => 'Need permission to edit school calendar', 'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
// ── IT ────────────────────────────────────────────────────────────
['title' => 'Projector not turning on in Room 204',
'description' => 'The classroom projector in Room 204 is unresponsive this morning. Power button flashes once, then nothing.'],
['title' => 'Need access to new staff shared drive',
'description' => 'I was added to the literacy team but still cannot see the shared Google Drive folder for curriculum planning.'],
['title' => 'Chromebook cart missing three chargers',
'description' => 'We checked the Grade 6 cart and three chargers are missing. Two devices are already below 20%.'],
['title' => 'Can\'t print to office copier',
'description' => 'Print jobs sit in queue and then disappear. I have tried restarting my laptop and reconnecting to Wi-Fi.'],
['title' => 'Classroom speakers crackling during assemblies',
'description' => 'Audio from the wall speakers has a crackling/popping sound whenever we play laptop audio through HDMI.'],
['title' => 'Google account keeps asking for password again',
'description' => 'My browser signs me out repeatedly and prompts for my password several times a day on the same Mac.'],
['title' => 'Staff laptop camera not detected in Meet',
'description' => 'Google Meet says no camera found even though the MacBook camera works in Photo Booth.'],
['title' => 'Need substitute teacher account reactivated',
'description' => 'A returning substitute cannot access school systems and says their previous login no longer works.'],
['title' => 'Student device filter blocking approved site',
'description' => 'An approved curriculum site is being blocked for students during class and I need it for tomorrow.'],
['title' => 'Need permission to edit school calendar',
'description' => 'I can view but not edit the school-wide Google Calendar for athletics scheduling.'],
['title' => 'Smartboard touch screen not responding',
'description' => 'The SMART Board in room 115 powers on and displays correctly but touch input stopped working this morning.'],
['title' => 'Wi-Fi dropping in the library wing',
'description' => 'Multiple staff have reported losing Wi-Fi connection in the library every afternoon around 1:30 PM.'],
['title' => 'Password reset needed for new EA',
'description' => 'Our new educational assistant started today and cannot log in. It looks like the account was created but the password was never sent.'],
['title' => 'Laptop won\'t connect to school network',
'description' => 'My personal MacBook was connecting to the staff network last week but now shows "cannot join" whenever I try.'],
['title' => 'Grade 8 iPads not syncing with MDM',
'description' => 'The Grade 8 iPad cart shows 12 devices as out of compliance in our MDM. Apps are not installing.'],
['title' => 'Classroom Apple TV not showing up',
'description' => 'The Apple TV in Room 309 disappeared from AirPlay device list on all our Macs. Tried a reboot — still not showing.'],
// ── Facilities ────────────────────────────────────────────────────
['title' => 'Gym thermostat is stuck at 27°C',
'description' => 'The gym feels like a greenhouse. Thermostat shows 27 degrees and the cooling does not seem to be kicking in.'],
['title' => 'Request for additional key fob',
'description' => 'We need an extra key fob for evening custodial coverage at the elementary entrance.'],
['title' => 'Broken desk in portable 3',
'description' => 'One of the student desks has a cracked support and is wobbling badly.'],
['title' => 'Request setup for parent info night',
'description' => 'Need 80 chairs, podium, projector, and two microphones in the gym for Thursday evening.'],
['title' => 'Door closer slamming in west hallway',
'description' => 'The west hallway fire door slams shut hard enough to shake the frame.'],
['title' => 'Water fountain on second floor leaking',
'description' => 'There is a slow but steady leak beneath the second-floor bottle filler station.'],
['title' => 'Bathroom stall door hinge broken — girls\' washroom',
'description' => 'The middle stall door in the main floor girls\' washroom has a broken hinge and cannot close properly.'],
['title' => 'Light bulbs out in staff parking lot',
'description' => 'Three of the five overhead lights in the north staff parking lot are burnt out. Safety concern after dark.'],
['title' => 'Ceiling tile collapsed in room 112',
'description' => 'A ceiling tile in the corner of Room 112 fell overnight. Debris on the floor but no one was hurt.'],
['title' => 'Exterior gate latch not catching',
'description' => 'The latch on the playground gate by the K-3 yard is not catching properly. Gate swings open on its own.'],
['title' => 'Plumbing noise in staff washroom',
'description' => 'There is a loud knocking/banging noise coming from the pipes in the main floor staff washroom when the toilet is flushed.'],
['title' => 'Cafeteria exhaust fan broken',
'description' => 'The exhaust fan in the cafeteria kitchen has stopped working. It is getting smoky during lunch prep.'],
['title' => 'Window blinds broken in Grade 4 room',
'description' => 'Two of the three window blinds in Grade 4 classroom cannot be lowered. Afternoon sun is making it impossible to see the board.'],
['title' => 'Graffiti on east exterior wall',
'description' => 'There is graffiti on the east-facing exterior wall near the secondary entrance that needs to be cleaned before the parent open house.'],
// ── HR ────────────────────────────────────────────────────────────
['title' => 'New employee onboarding checklist incomplete',
'description' => 'Our new EA starts Monday and I cannot find confirmation that payroll, email, and building access were submitted.'],
['title' => 'Benefits question about dependent coverage',
'description' => 'I need clarification on whether orthodontics falls under the current dependent coverage plan.'],
['title' => 'Payroll question on missed stipend',
'description' => 'A coaching stipend does not appear on my latest pay statement and I need to know whether it is pending.'],
['title' => 'Request ergonomic chair assessment',
'description' => 'I am having back pain and would like an ergonomic review of my workstation setup.'],
['title' => 'Update emergency contact information',
'description' => 'I recently moved and need to update my emergency contact and home address on file.'],
['title' => 'Parental leave documentation request',
'description' => 'I am planning parental leave starting in June and need to know what forms to submit and by when.'],
['title' => 'Professional development funding approval',
'description' => 'I would like to attend a literacy conference in May and need to know if PD funding is still available for this year.'],
['title' => 'Request for letter of employment',
'description' => 'My bank is requesting a letter of employment for a mortgage application. Can HR provide this?'],
['title' => 'Sick leave balance inquiry',
'description' => 'I am not sure how many sick days I have remaining this year. The employee portal is not showing my balance.'],
['title' => 'Contract renewal question for part-time staff',
'description' => 'My contract ends June 30 and I have not received any communication about renewal. Can someone confirm the status?'],
['title' => 'Request to change payroll deposit account',
'description' => 'I recently switched banks and need to update my direct deposit information before the next pay cycle.'],
['title' => 'Reference letter request for former employee',
'description' => 'A former staff member has asked us to provide a reference letter. Who should handle this and what is the process?'],
['title' => 'Vacation carryover from previous school year',
'description' => 'I had unused vacation days from last year that were supposed to carry over. They are not showing up in my current balance.'],
['title' => 'Volunteer supervision policy clarification',
'description' => 'We have a parent volunteer joining our classroom regularly. Do they need a criminal record check and what is our sign-in process?'],
['title' => 'Request for resignation acknowledgement letter',
'description' => 'I submitted my resignation two weeks ago and have not received any written acknowledgement from the school.'],
];
}
}

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

@@ -2,15 +2,22 @@
namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketAttachment;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject;
use Dashboard\Ticketing\Models\TicketMessage;
use Dashboard\Ticketing\Models\TicketParticipant;
use Dashboard\Ticketing\Models\TicketStatus;
use Dashboard\Ticketing\Models\TicketView;
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;
@@ -51,12 +58,25 @@ class TicketController extends Controller
return $messages->filter(fn($m) => !$m->is_internal)->values();
}
private function loadUserPrefs(): array
{
$raw = Setting::get('ticketing.pref.' . Auth::id(), '{}');
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
public function index(Request $request): Response
{
$user = Auth::user();
$agentGroupIds = $this->agentGroupIds();
$isAgent = count($agentGroupIds) > 0;
// Merge saved prefs as defaults — URL params always win
$pref = $this->loadUserPrefs();
if (! $request->filled('filter') && ! empty($pref['filter'])) {
$request->merge(['filter' => $pref['filter']]);
}
// If no groups exist at all, render a first-run / bootstrap state
$totalGroups = TicketingGroup::count();
if ($totalGroups === 0) {
@@ -65,15 +85,19 @@ class TicketController extends Controller
'groups' => [],
'priorities' => [],
'projects' => [],
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'isAgent' => $isAgent,
'filters' => [],
'ticketDetail' => null,
'detailAgents' => [],
'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0],
'isBootstrap' => true,
'userPrefs' => ['view_mode' => 'list', 'filter' => 'all'],
]);
}
$closedSlugs = TicketStatus::where('is_closed', true)->pluck('slug')->toArray();
$baseQuery = Ticket::query();
if ($isAgent) {
@@ -84,18 +108,18 @@ class TicketController extends Controller
$viewCounts = $isAgent
? [
'all' => (clone $baseQuery)->whereNotIn('status', ['resolved', 'closed'])->count(),
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed'])->count(),
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed'])->count(),
'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(),
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(),
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(),
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
]
: [
'all' => (clone $baseQuery)->count(),
'mine' => 0,
'unassigned' => 0,
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
'resolved' => (clone $baseQuery)->whereIn('status', ['resolved', 'closed'])->count(),
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
];
$query = Ticket::with(['group', 'priority', 'project']);
@@ -122,9 +146,9 @@ class TicketController extends Controller
} elseif ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']);
$query->whereIn('status', $closedSlugs);
} elseif (!$request->filled('status')) {
$query->whereNotIn('status', ['resolved', 'closed']);
$query->whereNotIn('status', $closedSlugs);
}
} else {
$query->where('submitter_id', $user->id);
@@ -134,7 +158,7 @@ class TicketController extends Controller
if ($request->filter === 'pending') {
$query->where('status', 'pending');
} elseif ($request->filter === 'resolved') {
$query->whereIn('status', ['resolved', 'closed']);
$query->whereIn('status', $closedSlugs);
}
}
@@ -149,11 +173,15 @@ class TicketController extends Controller
return $ticket;
});
$groups = TicketingGroup::when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))->get();
$priorities = PriorityLevel::whereNull('group_id')
->orWhereIn('group_id', $agentGroupIds ?: [0])
->orderBy('sort_order')
// 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')
->get();
@@ -178,6 +206,7 @@ class TicketController extends Controller
'project',
'messages.attachments',
'attachments',
'views',
])->whereKey($request->detail);
if ($isAgent) {
@@ -189,8 +218,10 @@ class TicketController extends Controller
$dt = $detailQuery->first();
if ($dt) {
$viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect();
$detailUserIds = collect([$dt->submitter_id, $dt->assigned_to])
->merge($dt->messages->pluck('user_id'))
->merge($viewUserIds)
->filter()
->unique();
$detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id');
@@ -198,13 +229,20 @@ class TicketController extends Controller
$dt->submitter = $detailUsers[$dt->submitter_id] ?? null;
$dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null;
// Bug #2: Filter internal notes for non-agents
// Filter internal notes for non-agents
$visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent);
$visibleMessages->each(function ($msg) use ($detailUsers) {
$msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null;
});
$dt->setRelation('messages', $visibleMessages);
// Augment views with user names (agents only — don't expose to submitters)
if ($isAgent) {
$dt->views->each(fn($v) => $v->user = $detailUsers[$v->user_id] ?? null);
} else {
$dt->setRelation('views', collect());
}
$ticketDetail = $dt;
if ($isAgent) {
@@ -219,12 +257,17 @@ class TicketController extends Controller
'groups' => $groups,
'priorities' => $priorities,
'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'isAgent' => $isAgent,
'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']),
'ticketDetail' => $ticketDetail,
'detailAgents' => $detailAgents,
'viewCounts' => $viewCounts,
'isBootstrap' => false,
'userPrefs' => [
'view_mode' => $pref['view_mode'] ?? 'list',
'filter' => $pref['filter'] ?? 'all',
],
]);
}
@@ -234,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) {
@@ -246,11 +292,7 @@ class TicketController extends Controller
]);
}
// Only pass global priorities + priorities scoped to accessible groups
$groupIds = $groups->pluck('id')->toArray();
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $groupIds))
->orderBy('sort_order')
->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
return Inertia::render('Ticketing/Create', [
'groups' => $groups,
@@ -265,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',
@@ -291,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);
@@ -319,12 +371,17 @@ class TicketController extends Controller
abort(403);
}
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments']);
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']);
// Bug #2: Filter internal notes for submitters
$visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent);
$userIds = $visibleMessages->pluck('user_id')->filter()->unique();
$participantUserIds = $ticket->participants->pluck('user_id');
$userIds = $visibleMessages->pluck('user_id')
->merge($participantUserIds)
->push($ticket->submitter_id)
->push($ticket->assigned_to)
->filter()->unique();
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
$visibleMessages->each(function ($msg) use ($users) {
@@ -333,14 +390,22 @@ class TicketController extends Controller
$ticket->setRelation('messages', $visibleMessages);
// Attach submitter and assignee info
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
// Augment participants with user info
$ticket->participants->each(function ($p) use ($users) {
$p->user = $users[$p->user_id] ?? null;
});
$agents = [];
if ($isAgent) {
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
}
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
->orderBy('sort_order')->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
return Inertia::render('Ticketing/Show', [
'ticket' => $ticket,
@@ -348,6 +413,7 @@ class TicketController extends Controller
'isManager' => $isAgent && $this->isManager($ticket->group_id),
'agents' => $agents,
'priorities' => $priorities,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
]);
}
@@ -358,8 +424,7 @@ class TicketController extends Controller
}
$ticket->load(['group', 'priority', 'project']);
$priorities = PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhere('group_id', $ticket->group_id))
->orderBy('sort_order')->get();
$priorities = PriorityLevel::orderBy('sort_order')->get();
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
@@ -378,44 +443,139 @@ class TicketController extends Controller
abort(403);
}
// Bug #3: Valid assignees are users with agent access to this group
$validAssigneeIds = TicketingAgentAccess::where('group_id', $ticket->group_id)
// Determine effective group (may be changing via a transfer)
$targetGroupId = $request->filled('group_id')
? (int) $request->input('group_id')
: $ticket->group_id;
// Valid assignees are agents of the target group
$validAssigneeIds = TicketingAgentAccess::where('group_id', $targetGroupId)
->pluck('user_id')
->toArray();
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
$validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(',');
$rules = [
'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|required|string',
'status' => 'sometimes|in:open,in_progress,pending,resolved,closed',
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
'status' => "sometimes|in:{$validStatusSlugs}",
// `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',
'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]),
];
$validated = $request->validate($rules);
// Bug #4: priority_id must belong to this group or be global
if (!empty($validated['priority_id'])) {
$priority = PriorityLevel::find($validated['priority_id']);
if ($priority && $priority->group_id !== null && $priority->group_id !== $ticket->group_id) {
abort(422, 'Priority does not belong to this ticket\'s group.');
// Group transfer: requester must be a manager of the target group
if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) {
if (! $this->isManager((int) $validated['group_id'])) {
abort(403, 'You must be a manager of the destination group to transfer tickets.');
}
// Clear assignee and project if they don't belong to the new group
$validated['assigned_to'] = null;
$validated['project_id'] = null;
} else {
unset($validated['group_id']); // no-op if unchanged
}
// Bug #4: project_id must belong to this group
// project_id must belong to the effective group
if (!empty($validated['project_id'])) {
$project = TicketingProject::find($validated['project_id']);
if ($project && $project->group_id !== $ticket->group_id) {
if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) {
abort(422, 'Project does not belong to this ticket\'s group.');
}
}
// 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.');
}
/**
* Record that an agent viewed this ticket (called after 10s of the pane being open).
* Throttled: at most one record per user per 2 hours per ticket.
*/
public function recordView(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
return response()->json(['ok' => false], 403);
}
$alreadySeen = TicketView::where('ticket_id', $ticket->id)
->where('user_id', Auth::id())
->where('viewed_at', '>=', now()->subHours(2))
->exists();
if (! $alreadySeen) {
TicketView::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'viewed_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
public function savePrefs(Request $request)
{
$validated = $request->validate([
'view_mode' => 'sometimes|in:list,kanban',
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved',
]);
if (empty($validated)) {
return response()->json(['ok' => false], 422);
}
$key = 'ticketing.pref.' . Auth::id();
$current = json_decode(Setting::get($key, '{}'), true) ?: [];
Setting::set($key, json_encode(array_merge($current, $validated)));
return response()->json(['ok' => true]);
}
public function destroy(Ticket $ticket)
{
if (!$this->isManager($ticket->group_id)) {
@@ -426,6 +586,204 @@ class TicketController extends Controller
return redirect()->route('ticketing.index');
}
/**
* Merge another ticket INTO this one.
* The source ticket's messages and attachments move here; source is marked merged.
*/
public function merge(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$validated = $request->validate([
'source_number' => 'required|string',
]);
$source = Ticket::where('number', $validated['source_number'])->first();
if (! $source) {
return back()->withErrors(['source_number' => 'Ticket not found.']);
}
if ($source->id === $ticket->id) {
return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']);
}
if ($source->status === 'merged') {
return back()->withErrors(['source_number' => 'That ticket has already been merged.']);
}
if (! $this->isAgent($source->group_id)) {
return back()->withErrors(['source_number' => 'You do not have access to that ticket.']);
}
DB::transaction(function () use ($ticket, $source) {
// Move messages to this ticket
TicketMessage::where('ticket_id', $source->id)
->update(['ticket_id' => $ticket->id]);
// Move attachments to this ticket
TicketAttachment::where('ticket_id', $source->id)
->update(['ticket_id' => $ticket->id]);
// Add a system note marking the merge
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'body' => "Merged from ticket {$source->number}.",
'is_internal' => true,
'source' => 'system',
]);
// Add source's submitter as a participant (if not already the submitter of target)
if ($source->submitter_id !== $ticket->submitter_id) {
TicketParticipant::firstOrCreate(
['ticket_id' => $ticket->id, 'user_id' => $source->submitter_id],
['added_by' => Auth::id()]
);
}
// Mark source as merged
$source->update([
'status' => 'merged',
'merged_into_id' => $ticket->id,
]);
});
return redirect()->route('ticketing.show', $ticket)
->with('success', "Ticket {$source->number} merged in.");
}
/**
* Split this ticket — create a copy with a new number, same metadata, duplicated messages.
*/
public function split(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$newTicket = DB::transaction(function () use ($ticket) {
$group = TicketingGroup::findOrFail($ticket->group_id);
$number = $group->nextTicketNumber();
$newTicket = Ticket::create([
'number' => $number,
'group_id' => $ticket->group_id,
'submitter_id' => $ticket->submitter_id,
'assigned_to' => $ticket->assigned_to,
'project_id' => $ticket->project_id,
'title' => $ticket->title . ' (split)',
'description' => $ticket->description,
'status' => 'open',
'priority_id' => $ticket->priority_id,
'due_date' => $ticket->due_date,
]);
// Duplicate messages (exclude system notes)
$ticket->load('messages');
foreach ($ticket->messages as $msg) {
TicketMessage::create([
'ticket_id' => $newTicket->id,
'user_id' => $msg->user_id,
'author_email' => $msg->author_email,
'body' => $msg->body,
'is_internal' => $msg->is_internal,
'source' => $msg->source,
]);
}
// Cross-reference notes on both tickets
TicketMessage::create([
'ticket_id' => $ticket->id,
'user_id' => Auth::id(),
'body' => "Split: new ticket {$newTicket->number} created from this ticket.",
'is_internal' => true,
'source' => 'system',
]);
TicketMessage::create([
'ticket_id' => $newTicket->id,
'user_id' => Auth::id(),
'body' => "Split from ticket {$ticket->number}.",
'is_internal' => true,
'source' => 'system',
]);
return $newTicket;
});
return redirect()->route('ticketing.show', $newTicket)
->with('success', "Ticket {$newTicket->number} created from split.");
}
/**
* Add a participant to a ticket.
*/
public function addParticipant(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$validated = $request->validate([
'user_id' => 'required|integer|exists:users,id',
]);
TicketParticipant::firstOrCreate(
['ticket_id' => $ticket->id, 'user_id' => $validated['user_id']],
['added_by' => Auth::id()]
);
return back()->with('success', 'Participant added.');
}
/**
* Remove a participant from a ticket.
*/
public function removeParticipant(Ticket $ticket, int $userId)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
TicketParticipant::where('ticket_id', $ticket->id)
->where('user_id', $userId)
->delete();
return back()->with('success', 'Participant removed.');
}
/**
* Search users by name/email — for the participant picker.
* Returns up to 10 matches, excluding existing participants and the submitter.
*/
public function searchUsers(Request $request, Ticket $ticket)
{
if (! $this->isAgent($ticket->group_id)) {
abort(403);
}
$q = $request->input('q', '');
if (strlen($q) < 2) {
return response()->json([]);
}
$excludeIds = $ticket->participants->pluck('user_id')
->push($ticket->submitter_id)
->filter()
->unique()
->values();
$users = DB::table('users')
->where(function ($query) use ($q) {
$query->where('name', 'like', "%{$q}%")
->orWhere('email', 'like', "%{$q}%");
})
->whereNotIn('id', $excludeIds)
->limit(10)
->get(['id', 'name', 'email']);
return response()->json($users);
}
public function myTickets(): Response
{
$tickets = Ticket::where('submitter_id', Auth::id())

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

@@ -2,12 +2,13 @@
namespace Dashboard\Ticketing\Http\Controllers;
use App\Models\Setting;
use Dashboard\Ticketing\Models\EmailConnection;
use Dashboard\Ticketing\Models\PriorityLevel;
use Dashboard\Ticketing\Models\TicketingAgentAccess;
use Dashboard\Ticketing\Models\TicketingGroup;
use Dashboard\Ticketing\Models\TicketingProject;
use Illuminate\Validation\Rule;
use Dashboard\Ticketing\Models\TicketStatus;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
@@ -63,7 +64,7 @@ class TicketingSettingsController extends Controller
}
}
public function index(): Response
public function index(?string $tab = null): Response
{
$this->requireAgentAccess();
@@ -71,13 +72,22 @@ 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()
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->get();
: TicketingAgentAccess::whereIn('group_id', $myGroupIds)->with('group')->get();
if ($agents->isNotEmpty()) {
$agentUserIds = $agents->pluck('user_id')->unique();
@@ -87,8 +97,7 @@ class TicketingSettingsController extends Controller
$priorities = $isBootstrap
? collect()
: PriorityLevel::where(fn($q) => $q->whereNull('group_id')->orWhereIn('group_id', $myGroupIds))
->orderBy('sort_order')->get();
: PriorityLevel::orderBy('sort_order')->get();
$projects = $isBootstrap
? collect()
@@ -101,14 +110,17 @@ class TicketingSettingsController extends Controller
: EmailConnection::whereIn('group_id', $myGroupIds)->get();
return Inertia::render('Ticketing/Settings', [
'activeTab' => $tab,
'groups' => $groups,
'agents' => $agents,
'priorities' => $priorities,
'projects' => $projects,
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
'emailConnections' => $emailConnections,
'myGroupIds' => $myGroupIds,
'isBootstrap' => $isBootstrap,
'isSiteAdmin' => $this->isSiteAdmin(),
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
]);
}
@@ -150,7 +162,6 @@ class TicketingSettingsController extends Controller
];
foreach ($defaults as $d) {
PriorityLevel::create([
'group_id' => $group->id,
'name' => $d['name'],
'color' => $d['color'],
'description' => null,
@@ -178,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',
]);
@@ -209,21 +320,17 @@ class TicketingSettingsController extends Controller
public function storePriority(Request $request)
{
$this->requireAgentAccess();
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'description' => 'nullable|string',
'sort_order' => 'integer|min:0',
'group_id' => 'nullable|exists:ticketing_groups,id',
]);
// If group_id given, caller must be manager of that group
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
PriorityLevel::create($validated);
return back()->with('success', 'Priority level created.');
@@ -231,33 +338,17 @@ class TicketingSettingsController extends Controller
public function updatePriority(Request $request, PriorityLevel $priority)
{
$this->requireAgentAccess();
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'description' => 'nullable|string',
'sort_order' => 'required|integer|min:0',
'group_id' => [
'nullable',
'exists:ticketing_groups,id',
Rule::in([$priority->group_id, null]),
],
]);
if (!empty($validated['group_id'])) {
$this->requireManagerAccess($validated['group_id']);
}
$priority->update($validated);
return back()->with('success', 'Priority level updated.');
@@ -265,16 +356,9 @@ class TicketingSettingsController extends Controller
public function destroyPriority(PriorityLevel $priority)
{
$this->requireAgentAccess();
if ($priority->group_id) {
$this->requireManagerAccess($priority->group_id);
} else {
// Global priorities require site admin
if (!$this->isSiteAdmin()) {
abort(403, 'Only site admins can manage global priorities.');
}
}
if ($priority->tickets()->exists()) {
return back()->withErrors([
@@ -292,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',
@@ -345,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',
@@ -400,4 +488,97 @@ class TicketingSettingsController extends Controller
$connection->delete();
return back()->with('success', 'Email connection removed.');
}
public function storeStatus(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// Derive a slug from the name (lowercase, underscores) — must be unique
$slug = \Str::slug($validated['name'], '_');
if (TicketStatus::where('slug', $slug)->exists()) {
$slug = $slug . '_' . time();
}
TicketStatus::create([
'slug' => $slug,
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
'is_closed' => $request->boolean('is_closed', false),
'is_system' => false,
]);
return back()->with('success', 'Status created.');
}
public function updateStatus(Request $request, TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
$validated = $request->validate([
'name' => 'required|string|max:100',
'color' => 'required|string|regex:/^#[0-9a-fA-F]{6}$/',
'sort_order' => 'required|integer|min:0',
'is_closed' => 'boolean',
]);
// System statuses: only allow name, color, sort_order to be changed — not is_closed
$update = [
'name' => $validated['name'],
'color' => $validated['color'],
'sort_order' => $validated['sort_order'],
];
if (! $status->is_system) {
$update['is_closed'] = $request->boolean('is_closed', false);
}
$status->update($update);
return back()->with('success', 'Status updated.');
}
public function destroyStatus(TicketStatus $status)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage ticket statuses.');
}
if ($status->is_system) {
return back()->withErrors(['status' => 'System statuses cannot be deleted.']);
}
if ($status->tickets()->exists()) {
return back()->withErrors(['status' => 'Cannot delete a status that is in use by tickets.']);
}
$status->delete();
return back()->with('success', 'Status removed.');
}
public function updateAutomation(Request $request)
{
if (! $this->isSiteAdmin()) {
abort(403, 'Only site admins can manage automation settings.');
}
$validated = $request->validate([
'auto_close_days' => 'required|integer|min:0|max:365',
]);
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
return back()->with('success', 'Automation settings saved.');
}
}

View File

@@ -3,19 +3,13 @@
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class PriorityLevel extends Model
{
protected $table = 'ticketing_priority_levels';
protected $fillable = ['group_id', 'name', 'color', 'description', 'sort_order'];
public function group(): BelongsTo
{
return $this->belongsTo(TicketingGroup::class, 'group_id');
}
protected $fillable = ['name', 'color', 'description', 'sort_order'];
public function tickets(): HasMany
{

View File

@@ -10,7 +10,7 @@ class Ticket extends Model
{
protected $fillable = [
'number', 'group_id', 'submitter_id', 'assigned_to', 'project_id',
'title', 'description', 'status', 'priority_id', 'due_date',
'title', 'description', 'status', 'priority_id', 'due_date', 'merged_into_id',
];
protected $casts = [
@@ -41,4 +41,19 @@ class Ticket extends Model
{
return $this->hasMany(TicketAttachment::class, 'ticket_id');
}
public function views(): HasMany
{
return $this->hasMany(TicketView::class, 'ticket_id')->orderBy('viewed_at');
}
public function participants(): HasMany
{
return $this->hasMany(TicketParticipant::class, 'ticket_id');
}
public function mergedInto(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'merged_into_id');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketParticipant extends Model
{
public $timestamps = false;
const CREATED_AT = 'created_at';
protected $fillable = ['ticket_id', 'user_id', 'added_by'];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class TicketStatus extends Model
{
protected $table = 'ticketing_statuses';
protected $fillable = ['slug', 'name', 'color', 'sort_order', 'is_closed', 'is_system'];
protected $casts = [
'is_closed' => 'boolean',
'is_system' => 'boolean',
];
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'status', 'slug');
}
}

20
src/Models/TicketView.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TicketView extends Model
{
public $timestamps = false;
protected $table = 'ticket_views';
protected $fillable = ['ticket_id', 'user_id', 'viewed_at'];
protected $casts = ['viewed_at' => 'datetime'];
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
}

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,11 +6,27 @@ 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,7 +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 () {
@@ -28,8 +41,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
// ── Settings (must be before /{ticket} wildcard) ──────────────────────
Route::middleware('permission:ticketing.settings')->group(function () {
Route::get('/settings', [TicketingSettingsController::class, 'index']) ->name('settings');
Route::get('/settings/{tab}', [TicketingSettingsController::class, 'index'])
->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');
@@ -41,6 +58,10 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
Route::post('/settings/email', [TicketingSettingsController::class, 'storeEmailConnection'])->name('settings.email.store');
Route::put('/settings/email/{connection}', [TicketingSettingsController::class, 'updateEmailConnection'])->name('settings.email.update');
Route::delete('/settings/email/{connection}', [TicketingSettingsController::class, 'destroyEmailConnection'])->name('settings.email.destroy');
Route::put('/settings/automation', [TicketingSettingsController::class, 'updateAutomation']) ->name('settings.automation.update');
Route::post('/settings/statuses', [TicketingSettingsController::class, 'storeStatus']) ->name('settings.statuses.store');
Route::put('/settings/statuses/{status}', [TicketingSettingsController::class, 'updateStatus']) ->name('settings.statuses.update');
Route::delete('/settings/statuses/{status}', [TicketingSettingsController::class, 'destroyStatus']) ->name('settings.statuses.destroy');
});
// ── Wildcard /{ticket} routes (last, after all fixed paths) ──────────
@@ -52,6 +73,12 @@ Route::middleware(['web', 'auth', 'app.access:ticketing'])
Route::get('/{ticket}/edit', [TicketController::class, 'edit']) ->name('edit');
Route::put('/{ticket}', [TicketController::class, 'update']) ->name('update');
Route::delete('/{ticket}', [TicketController::class, 'destroy']) ->name('destroy');
Route::post('/{ticket}/view', [TicketController::class, 'recordView']) ->name('tickets.view');
Route::post('/{ticket}/merge', [TicketController::class, 'merge']) ->name('merge');
Route::post('/{ticket}/split', [TicketController::class, 'split']) ->name('split');
Route::post('/{ticket}/participants', [TicketController::class, 'addParticipant']) ->name('participants.store');
Route::delete('/{ticket}/participants/{userId}', [TicketController::class, 'removeParticipant'])->name('participants.destroy');
Route::get('/{ticket}/users/search', [TicketController::class, 'searchUsers']) ->name('users.search');
});
Route::middleware('permission:ticketing.view,ticketing.manage')->group(function () {