23 Commits

Author SHA1 Message Date
central-worker-1
332551e2e3 The participants picker is sorted by the name it shows (#1706)
QA on [central-ticketing] #1598, reading the dropdown back after the
picker became chips: typing `an` listed "Toews, Fast, Ivanov, Mwangi,
Bergman, Anderson" — to an operator, no order at all.

It was in fact perfectly ordered. `searchUsers()` came out of #1598's
chip conversion with `->orderBy('name')`, and `users.name` is the cached
"First Last" ("Abigail Toews", "Andrew Fast", "Avery Ivanov") — the one
string a chip row no longer prints. A list labelled "Last, First" and
collated by "First Last" reads as unsorted, which is #524's own reported
defect; the sibling conversion on [central-staff-employment] #1597 names
it as the thing it had to fix at the same moment.

So the collation moves with the label: `PersonNames::orderUsers()`, the
platform's single answer, which the reference search
(`admin.settings.users.search`) and the other chipped pickers already
use, and which honours the operator's `name_format` rather than assuming
surname-first.

Not cosmetic: the sort runs before `limit(10)`, so on a common surname
the two collations offer a different set of people, not the same set
shuffled.

Three tests in the shell suite (TicketParticipantsAreChipsTest) — the
collation, the `name_format` setting, and which ten survive the limit.
The first and third are red against `orderBy('name')`.
2026-09-11 13:47:08 -07:00
central-worker-4
c492becbdb The participants picker searches, and answers, in chips (#1598)
The operator, 11:46 PDT 2026-09-10, having just had the device picker
converted: "are there other places we can search users where we're not
using the chips? please go through and check." The lead's audit found ten
person searches on the platform — four drawing chips, six not — and this
is central-ticketing's.

`ticketing.users.search` answered `DB::table('users')->get(['id', 'name',
'email'])`, and `show()` handed the Participants card the same three
columns for the submitter and for everybody already on the ticket. So the
one card on this page that is entirely about WHO SOMEBODY IS drew three
text rows in three different shapes: a name over an address in the
dropdown, a name-or-address string per participant, and a third spelling
for the submitter. An agent adding a participant to a ticket about a
child chooses between the child and the parent of the same surname with
no role dot to tell them apart.

And it was worse than untidy. Name and email were the SHIPPED DEFAULT for
a chip, so a school that configured the student chip to grade + homeroom
kept being shown an address here — on the one people surface the
operator's own setting could not reach. That is the defect #871 exists to
remove, and this endpoint was still carrying it.

Both halves now go through `PersonChip::rows()`, so what a row SAYS is
the setting rather than a select list in this file, and the person PICKED
is byte-for-byte the person picked FROM — one serializer, one answer. The
search's payload is now the same, key for key, as
`admin.settings.users.search`; the test compares the two endpoints against
each other rather than against a literal list, because a literal passes on
the day it is written and then lets this surface fall behind the next key
the shared row grows.

`PersonChip::columns()` is the select list, not `['id', 'name', 'email']`:
a chip line may name any user column, and a constrained select hands the
helper a model whose attribute is simply ABSENT — which it cannot tell
from a person who has no value, so the line goes blank for everybody with
nothing failing.

`rows()` and NOT the model, in `show()`. That map also feeds the message
bylines and the assignee, so handing the page a `User` would put every
column of `users` — `preferred_pin`, `preferred_password`,
`date_of_birth` — in front of any agent who opens a ticket. `rows()` is
`{id, name, email, person}` and `person` is the curated allow-list. There
is a test that a PIN cannot reach the page.

WHO IS SEARCHABLE DOES NOT CHANGE, per the card. Same two-character
floor, same name-or-email `like`, same limit of 10, same exclusion of the
people already on the ticket, same agent gate; `User` carries no global
scope or soft delete `DB::table('users')` did not. The name-or-email
clause stays inside its own closure on purpose — flattened beside the
exclusion it is `(name) OR (email AND NOT IN)` by SQL's own precedence,
so a name match jumps the exclusion, which is the hole the same conversion
opened on [central-inventory] #1594. `orderBy('name')` is the one visible
difference and it is new: the old query had no order at all, so the same
two letters could list the same ten people differently twice running.

The Vue half, the source guard and the feature tests are on
dashboard-shell's branch of the same name.
2026-09-10 12:34:50 -07:00
central-worker-2
2b6d9b1651 Deleting a merge target un-merges what was folded into it (#1567)
`tickets.merged_into_id` is `nullOnDelete()` and `destroy()` was a one-liner, so
deleting the surviving ticket of a merge blanked the pointer on every ticket
merged into it and left `status = 'merged'` behind. The row that came out was
read-only for good (#920 guards on the status), carried a banner naming a ticket
that no longer existed, reported `merged_inconsistent = false` — the flag that
exists to catch exactly this — and dropped out of the Merged view, which was
scoped by the pointer, leaving it visible only under Resolved.

`StrandedMerge::handleTargetDeletion()` runs inside destroy()'s transaction,
before the row goes: sources are RE-POINTED at the doomed ticket's own merge
target when it has one (the messages really did travel that far up the chain),
otherwise UN-MERGED to `closed` — out of the work queues, as they were, but
editable, reopenable and answerable again — with a system note naming what was
deleted and why. #938's dangling pointers are simply cleared: those rows are
somebody's open work. The flash names every ticket that changed, and the delete
confirm names them before the click instead of the generic "cannot be undone".

The shape prod already holds gets a name: `scopeStrandedMerge()`, reported as
`merged_inconsistent` (now the union of both broken shapes) plus
`merged_stranded` so a page can tell which fix to offer, listed by the Merged
view via `scopeEverMerged()`, and released one at a time by `unmerge`. #938's
repair scope is deliberately untouched — it writes `status = 'merged'`, which a
stranded row already has, and matching it would break its idempotency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:36:07 -07:00
central-worker-3
5eb2ec7bcf The Unassigned queue stops offering merged tickets as work (#1054)
The count and the list were the same question spelled two ways in one
method: the sidebar counts scope by $closedSlugs, read from
ticketing_statuses, while the `unassigned` and `mine` list branches
carried a hand-written ["resolved", "closed"] that predates the `merged`
status. So a correctly merged ticket — status merged, merged_into_id set,
its own Show page already calling it read-only — stayed in the queue
agents pull new work from, and the page contradicted itself on screen:
"Unassigned 4" beside five rendered rows.

Both queues now scope by $closedSlugs, so they exclude exactly what their
own counts exclude. The scoping is by STATUS and not by merged_into_id on
purpose: #938 rows (pointer set, status still open) are live, editable
work until a human repairs them, and dropping them out of the queue would
hide the rows that most need one. There is a NEGCTRL test for that.

Also, the secondary finding: ?filter=closed was accepted, silently
returned the All-Open set and was echoed back in props.filters as though
a Closed view existed. The sidebar has six views and that is not one of
them, so an unrecognised filter normalises to `all` and says so. The list
is now a constant that savePrefs() validates against too, rather than the
same six slugs written out twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:06:02 -07:00
central-worker-4
420ef78603 A row the merge bug minted is visible, fixable, and repairable in bulk (#938)
#920 made a merged ticket read-only on the server and shipped NO data change,
deliberately: its guard reads `status === 'merged'` and not
`merged_into_id !== null`, so the rows the bug had already produced — the
pointer set, the status back to `open` — stayed EDITABLE and could be corrected
in the product instead of in SQL. What it left behind was rows that are
simultaneously live in the All Open queue and merged into another ticket, with
nothing on any screen saying so and nothing offering to fix them.

Both halves of the card ship, because option A is a BEHAVIOUR RULE and a
behaviour rule is a setting:

  B — the office fixes them one at a time. `merged_inconsistent` rides on the
      model (Ticket::$appends), so the list row, the index detail pane and the
      Show header all draw the same amber badge — "merged_into set but status
      open — fix?" — off one predicate rather than three copies of an
      expression. The badge IS the button: it opens a modal that says what will
      change and what will not, and posts to a new `mark-merged` verb that
      writes `status` and nothing else. A new "Merged" view lists every ticket
      folded into another one BY THE POINTER, so the consistent and the broken
      appear side by side; a status filter would show one or the other and
      never the pair worth comparing.

  A — the migration re-marks all of them at once, and it is INERT unless
      `ticketing.auto_repair_merged_on_deploy` is on. It defaults OFF, which is
      also what a box that has never heard of the setting does, so this can
      ship ahead of the operator's decision and change nothing unasked. The
      toggle is on the Automation tab with the live count beside it, because
      nobody should arm a bulk data change without seeing how many rows it
      touches.

`MergedTicketRepair` is one implementation with three callers for one reason:
"which rows are broken" and "what fixing one means" must not drift, or the
badge offers a fix on a row the migration skips.

THE PROD COUNT THE CARD ASKS FOR IS NOT IN HERE, and it could not be: this
worker box holds no ssh private key, so central-prod is unreachable from it and
there is no prod container locally. `php artisan ticketing:repair-merged
--dry-run` is the read-only answer for whoever does have access — it prints the
card's own SELECT COUNT and lists the rows, and `--force` (not the bare form)
is the only spelling that writes.

Repairing NEVER reverts. Messages, participants, assignee and title edits made
to a row while it was wrongly editable are somebody's work and the only copy of
it; there is no record of what the fields held before the bad save, so
"reverting" would invent a previous state rather than restore one.

Proved by tests/Feature/Ticketing/MergedTicketInconsistentRowsTest.php (22
tests) in dashboard-shell, over the fixture the card names — one inconsistent
row beside a genuinely merged one, both produced by the real merge endpoint.
The NEGCTRL half is what keeps it honest: the consistent row and an ordinary
open ticket must come out of every path untouched, and a repaired row must keep
the reply and the rename it was given after the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 02:53:23 -07:00
central-worker-4
5315afc239 Put filterMessagesForRole's docblock back on filterMessagesForRole
The #920 guard landed between the comment and the function it describes.
2026-09-03 14:13:44 -07:00
central-worker-4
c0225d7691 A merged ticket is read-only on the server, not just on the page (#920)
The Show page announced "This ticket has been merged and is read-only.",
hid the reply composer and withheld the Merge button — and that was the
whole guard. It was presentation, on one page.

The Edit link and the Split button were still rendered, `edit()` still
served the form (with its Status select blank, because `merged` was not
one of the five slugs the form hard-coded), and `update()` took the save
with `errors: {}`: status went back to `open` while `merged_into_id` kept
pointing at the merge target. The row that came out was live and
assignable in the All Open queue AND merged into another ticket, holding
none of its own messages because the merge had moved them — an empty
shell that reads as a real duplicate of the ticket it was folded into.

So the rule now lives on the server, once — `Ticket::isMerged()` and
`refuseIfMerged()` — and every write the page presents as unavailable
calls it: update, edit, split, participants add/remove, and a reply or an
attachment (both hidden by the banner, neither refused by its route).

Two deliberate choices:

  - the guard reads `status === 'merged'`, NOT `merged_into_id !== null`.
    Reading the pointer would look stricter and would freeze every row
    this bug already minted (`status = open, merged_into_id = 56`), so
    the only way to correct one would be SQL.
  - it refuses with a redirect carrying the reason, not `abort(403)`.
    The realistic way to arrive here is a page that was already open when
    somebody else merged the ticket, and that operator is better served
    by the sentence than by an error screen.

The blank Status select was the report's second symptom, and it was the
same defect one layer up: the five hard-coded options meant any ticket
whose status was not among them — `merged`, or any status added on the
Statuses settings tab — rendered with nothing selected, and the first
save silently rewrote the status to whatever happened to be showing. The
options now come from the same table `update()` validates against.
2026-09-03 13:59:24 -07:00
ef4d0c4f93 A system note is a ticket message source
Merge and Split both 500ed. The audit note each writes uses `source = 'system'`
and the column is `enum('web','email')`, so the insert threw inside the
transaction and nothing committed — which is why the operator saw no state change
at all rather than a half-done merge.

Widened to an enum rather than a varchar, and the reasoning is the same test that
made `tickets.status` a varchar in `2026_04_17_000002`: statuses became
operator-definable rows, so their domain is open. `source` is the opposite — all
three values are set by code, an operator can never add a fourth, and the enum is
what keeps a typo'd source loud instead of a silently unfilterable row.

`TicketMessage::system()` is now the only place a system note is written. Three
scattered literals is how this reopens.

A SECOND 500 ON THE SAME ACTION, unreported and one longer title away. `split()`
appended ' (split)' to a title the product validates at max:255, so splitting a
full-length title produced 263 characters and the identical hard failure. Bounded
at the write rather than widened, and the distinction is written down: widen when
the data is right and the column is wrong; bound when the column expresses a real
product rule. 255 is the product's own limit, so a 263-character title would be
un-editable forever — the edit form would bounce it every time.

And `split()` now honours its own comment. Line 682 has always said "exclude
system notes" while the code copied them; it never mattered because no system row
could exist. The moment the enum accepts them, splitting a merged ticket would
carry "Merged from ticket QAX-0005." onto a ticket that was never merged.
2026-08-29 11:09:42 -04:00
0e48949114 A ticket reference is never re-issued, and notifications name their recipient (#224, #228)
#224 — the next number came from the live rows, so deleting a ticket freed its
reference and two tickets could both be QAX-0004. An old email then points at a
different ticket than it did. The sequence is now held on the group and only ever
moves forward.

#228 — ticketing notifications carried no user, so the Notification Log showed
the message type where the recipient belongs and the per-person opt-out was
ignored: somebody who had opted out still received them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ec3REU4LQmhuchRRn6dZHk
2026-08-27 19:37:06 -04:00
a4a365da3b fix(ticketing): due_date is accepted, and now actually stored
Setting a ticket's Due Date returned 303 with "Ticket updated." and never saved
the value. Reproduced by the QA sweep on all three paths that expose the field —
the edit form, the update endpoint and creation.

A success message covering a silent discard is worse than an error: nobody goes
looking, and the operator believes a date is set that no query will ever find.

2,862 passed across the affected suites. Committed by the lead — the authoring
agent stopped without reporting, so the code is the account of record.
2026-08-25 15:56:09 -04:00
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
36 changed files with 3236 additions and 2286 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

@@ -0,0 +1,82 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* A ticket reference must never be issued twice.
*
* `TicketingGroup::nextTicketNumber()` derived the next reference from the MAX
* suffix over the group's **live** tickets. Tickets are hard-deleted (no
* SoftDeletes on the model), so deleting the newest ticket in a group put the
* number back in the pool and the next ticket was issued the same reference —
* two different tickets both carrying `QAX-0004`.
*
* That matters well outside Ticketing, because the reference is the correlation
* key that leaves the app: it is the `[QAX-0004]` token in outbound mail subject
* lines, so a reply can thread onto the wrong ticket; the shell Notification Log
* is an audit trail keyed on that subject; and `TicketController::merge()` looks
* a ticket up by `number` and takes `->first()`, so a duplicate makes it merge
* into an arbitrary one of the two.
*
* This column is that pool: a per-group high-water mark that only ever goes up,
* independent of whether the ticket it was issued to still exists.
*
* **The backfill is the load-bearing half of this migration.** Left at 0, the
* first ticket created after deploy would be issued `-0001` on a group that has
* been running for months — turning a bug that reused one number into one that
* reuses every number. So each group is seeded with the same MAX the old code
* would have read, scoped to its CURRENT prefix exactly as the old query was.
* The next reference issued after this migration is therefore identical to the
* one the old code would have issued; all that changes is that it can no longer
* go backwards afterwards.
*
* Expand-only: one column added with a default, nothing dropped or renamed, and
* existing rows keep their meaning. Note there is deliberately NO unique index
* on `tickets.number` — production already contains duplicates created by this
* bug (the report evidences at least `QAX-0001`–`QAX-0004`), so adding one here
* would fail the deploy. De-duplicating the existing rows is its own decision,
* with its own migration, and is not made silently here.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('ticketing_groups', 'last_ticket_number')) {
Schema::table('ticketing_groups', function (Blueprint $table) {
$table->unsignedInteger('last_ticket_number')->default(0)->after('prefix');
});
}
// Seed the high-water mark from the numbers already issued. Same shape as
// the query this replaces: the group's own tickets, restricted to its
// current prefix, suffix cast to an integer.
foreach (DB::table('ticketing_groups')->select('id', 'prefix')->get() as $group) {
$prefix = $group->prefix.'-';
$max = (int) DB::table('tickets')
->where('group_id', $group->id)
->where('number', 'like', $prefix.'%')
->max(DB::raw('CAST(SUBSTRING(number, '.(strlen($prefix) + 1).') AS UNSIGNED)'));
// Never lower a counter that already holds a larger value — this
// migration must be safe to re-run and must never hand back a number.
DB::table('ticketing_groups')
->where('id', $group->id)
->where('last_ticket_number', '<', $max)
->update(['last_ticket_number' => $max]);
}
}
public function down(): void
{
// Deliberate NO-OP, matching the archived_at precedent above it:
// DashboardDoctor greps the whole migration file for drop/rename patterns,
// so a dropColumn even in down() raises "pending destructive migration" on
// every host where this is still pending. An integer column with a default
// costs nothing to leave behind — and dropping it would silently restore
// the reference-reuse bug.
}
};

View File

@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* MERGE AND SPLIT WROTE A `source` THE COLUMN DID NOT ACCEPT. [central-ticketing] #415
*
* Both actions write an audit note into `ticket_messages` with `source = 'system'`,
* and the column was created as `enum('web','email')`. Under strict mode the insert
* throws `Data truncated for column 'source'` INSIDE the transaction, so nothing
* commits: no merge, no split, no partial state, and a generic 500 page. Every
* guard around them worked — a missing reference, a self-merge and an absent
* `source_number` were all handled correctly — so only the happy path was broken,
* which is why it survived.
*
* ── THE WHOLE DOMAIN, NOT THE ONE IN THE TRACE ──────────────────────────────
*
* The trace named `'system'`. Widening to accept only that would be a guess about
* how many more there are, so every value written to this column anywhere in the
* platform was enumerated first:
*
* 'web' TicketMessageController::store(), the demo seeder
* 'email' the demo seeder (no inbound-mail ingester exists yet)
* 'system' TicketController::merge() and ::split(), the three notes
*
* `split()` also copies `$msg->source` verbatim when duplicating a thread, which
* introduces no fourth value. So the set is closed at three and this runs once.
*
* ── WHY AN ENUM AND NOT A VARCHAR ───────────────────────────────────────────
*
* `tickets.status` went the other way — `2026_04_17_000002` deliberately widened it
* from an enum to `varchar(50)` — and the difference is the test worth writing down:
* statuses became OPERATOR-DEFINABLE rows in `ticketing_statuses`, so no enum could
* ever hold them. `source` is the opposite: all three values are set by code, an
* operator has no way to add a fourth, and the enum is what makes a typo'd source a
* loud failure rather than a silently unfilterable row.
*
* The write side is bounded too — `TicketMessage::system()` is now the only place a
* system note is created, so the three scattered `'source' => 'system'` literals that
* caused this cannot reappear one at a time.
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(
"ALTER TABLE ticket_messages
MODIFY COLUMN source ENUM('web','email','system') NOT NULL DEFAULT 'web'"
);
}
public function down(): void
{
// Rewrite the rows first: narrowing an enum that still holds 'system' turns
// those rows into the empty string rather than failing, which would leave the
// merge and split notes present but unattributable. 'web' is the honest
// fallback — they were written by a person pressing a button in the app.
DB::table('ticket_messages')->where('source', 'system')->update(['source' => 'web']);
DB::statement(
"ALTER TABLE ticket_messages
MODIFY COLUMN source ENUM('web','email') NOT NULL DEFAULT 'web'"
);
}
};

View File

@@ -0,0 +1,73 @@
<?php
use Dashboard\Ticketing\Support\MergedTicketRepair;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Log;
/**
* OPTION A OF [central-ticketing] #938 — the repair, behind a setting that is OFF.
*
* The rows the merge bug minted (`merged_into_id` set, `status` back to `open`)
* are data, not code, and #920 deliberately shipped no data change. This
* migration is the one that would: it re-marks every such row `merged`, which
* makes it read-only under #920's guard, and it leaves every message,
* participant and field edit made after the merge exactly where it is.
*
* IT DOES NOT RUN UNLESS THE OPERATOR HAS ASKED. `MergedTicketRepair::SETTING`
* — "auto-repair inconsistent merged tickets on deploy" — defaults off, and a
* box that has never heard of it therefore deploys this migration and changes
* nothing at all. That is what lets the code ship ahead of the decision: the
* office gets the badge and the one-click fix now (option B), and turning this
* on is the operator's separate, later, reversible act (option C).
*
* WHAT IT ALWAYS DOES, EITHER WAY, IS COUNT AND SAY SO. The dry-run count goes
* to the log on every deploy whether the repair is armed or not, because the
* number is the thing #938 was blocked on and a deploy is the one moment
* something is definitely looking at prod's database.
*/
return new class extends Migration
{
public function up(): void
{
// The dry run, first and unconditionally.
$count = MergedTicketRepair::count();
Log::info(sprintf(
'[ticketing] #938 dry run: %d ticket(s) have merged_into_id set with a status other than "merged".',
$count,
));
if ($count === 0) {
return;
}
if (! MergedTicketRepair::enabledOnDeploy()) {
Log::info(sprintf(
'[ticketing] #938: leaving those %d row(s) editable — "%s" is off. '
.'Turn it on in Ticketing → Settings → Automation, or fix them one at a '
.'time with the "Mark merged" button on each ticket.',
$count,
MergedTicketRepair::SETTING,
));
return;
}
MergedTicketRepair::repairAll('migration 2026_09_04_000001');
}
/**
* There is no down migration, and that is a decision rather than an omission.
*
* Reverting would mean putting each row's status back to what it was before
* the bad save, and nothing anywhere records what that was — the bug
* overwrote it. Setting them all back to `open` would not be a rollback; it
* would be re-creating the corrupt shape on rows that may have been merged
* legitimately years ago. A row repaired in error is corrected the same way
* any other status is: an agent edits the ticket.
*/
public function down(): void
{
// Intentionally empty — see the note above.
}
};

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,67 @@
<?php
namespace Dashboard\Ticketing\Console\Commands;
use Dashboard\Ticketing\Support\MergedTicketRepair;
use Illuminate\Console\Command;
/**
* THE DRY-RUN COUNT, AND THE REPAIR — [central-ticketing] #938.
*
* The first question #938 asks is a number: how many rows on prod carry
* `merged_into_id` with a status other than `merged`. That number decides
* whether the office corrects them one at a time from the badge or whether a
* deploy re-marks them all, and there was no way to ask for it that did not
* involve typing a raw UPDATE-shaped statement into tinker on the production
* box. So `--dry-run` is the default and is the point of this command:
*
* php artisan ticketing:repair-merged --dry-run # count + list, writes NOTHING
* php artisan ticketing:repair-merged --force # re-mark them all
*
* `--force` is required to write, rather than `--dry-run` being required not
* to. A repair command whose bare form repairs is one fat-finger away from
* being the incident it was written to clean up after.
*/
class RepairMergedTickets extends Command
{
protected $signature = 'ticketing:repair-merged
{--dry-run : Count and list the inconsistent rows; write nothing (the default).}
{--force : Actually re-mark every inconsistent row as merged.}';
protected $description = 'Report — or, with --force, repair — tickets whose merged_into_id is set while their status is not "merged" (#938).';
public function handle(): int
{
$rows = MergedTicketRepair::rows();
$this->line(sprintf(
'SELECT COUNT(*) FROM tickets WHERE merged_into_id IS NOT NULL AND status <> \'merged\' => %d',
$rows->count(),
));
if ($rows->isEmpty()) {
$this->info('Nothing is inconsistent. No repair to make.');
return 0;
}
$this->table(
['id', 'number', 'status', 'merged_into_id', 'updated_at'],
$rows->map(fn ($t) => [
$t->id, $t->number, $t->status, $t->merged_into_id, (string) $t->updated_at,
])->all(),
);
if (! $this->option('force')) {
$this->comment('Dry run — nothing was written. Re-run with --force to re-mark these as merged.');
return 0;
}
$changed = MergedTicketRepair::repairAll('artisan ticketing:repair-merged');
$this->info("Re-marked {$changed} ticket(s) as merged. Their messages and participants are untouched.");
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

@@ -29,6 +29,11 @@ class TicketAttachmentController extends Controller
abort(403);
}
// Same read-only rule as a reply — see TicketMessageController::store(). #920
if ($ticket->isMerged()) {
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
}
$request->validate([
'file' => 'required|file|max:' . (self::MAX_SIZE_MB * 1024),
// Bug #5: message_id must belong to THIS ticket, not any arbitrary message
@@ -49,7 +54,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 +76,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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,19 @@ class TicketMessageController extends Controller
abort(403);
}
/*
* A merged ticket takes no new messages — [central-ticketing] #920.
*
* Show.vue replaces the composer with "This ticket has been merged and is
* read-only.", and that was the whole of the guard: a POST straight to this
* route (or from a page that was already open when the merge happened) still
* wrote a message onto a ticket whose thread has moved to the merge target,
* where nobody working the ticket would ever see it.
*/
if ($ticket->isMerged()) {
return back()->withErrors(['ticket' => Ticket::READ_ONLY_REASON]);
}
$validated = $request->validate([
'body' => 'required|string',
'is_internal' => 'boolean',
@@ -62,9 +75,37 @@ 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);
if (! $submitter) {
return; // a ticket raised on behalf of somebody with no account
}
$url = route('ticketing.show', $ticket->id);
$author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent';
// The submitter, not their address. Handing Notify a string dropped
// to_user_id and to_name (so the Log printed the message type where the
// name goes) and skipped the preference check entirely, so the opt-out for
// this category did nothing. Notify resolves the address itself.
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
\Dashboard\Ticketing\Support\TicketingNotifier::REPLIED,
$submitter,
"[{$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,14 @@
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 Dashboard\Ticketing\Support\MergedTicketRepair;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
@@ -63,7 +65,7 @@ class TicketingSettingsController extends Controller
}
}
public function index(): Response
public function index(?string $tab = null): Response
{
$this->requireAgentAccess();
@@ -71,13 +73,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 +98,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 +111,22 @@ 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),
// #938 — the deploy-time repair, and how many rows it would touch. The
// count is what turns an abstract toggle into a decision somebody can
// actually make, so it is shown beside it whether the toggle is on or off.
'autoRepairMerged' => MergedTicketRepair::enabledOnDeploy(),
'inconsistentMergedCount' => MergedTicketRepair::count(),
]);
}
@@ -150,7 +168,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 +195,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 +326,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 +344,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 +362,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 +382,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 +437,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 +494,107 @@ 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',
'auto_repair_merged_on_deploy' => 'sometimes|boolean',
]);
Setting::set('ticketing.auto_close_days', $validated['auto_close_days']);
// #938 option A is a behaviour rule, so it is a setting, and it defaults
// off: a deploy must never rewrite rows nobody asked it to rewrite.
if (array_key_exists('auto_repair_merged_on_deploy', $validated)) {
Setting::set(
MergedTicketRepair::SETTING,
$validated['auto_repair_merged_on_deploy'] ? '1' : '0',
);
}
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

@@ -2,19 +2,77 @@
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Ticket extends Model
{
/** Set only by TicketController::merge(); never offered by any form. */
public const STATUS_MERGED = 'merged';
/**
* WHERE AN UN-MERGED TICKET LANDS — [central-ticketing] #1567.
*
* A merge is a way of closing a ticket, so a ticket that is un-merged because
* its target was deleted goes to `closed` and not to `open`: it stays out of
* the All Open / Mine / Unassigned work queues, exactly as it was before, and
* the only thing that changes is that it is no longer read-only, so an agent
* can reopen it, reply to it or reassign it. Un-merging to `open` would push
* somebody's tidied-away ticket back into a live queue without being asked.
*
* It is one of the six values the `tickets.status` ENUM allows and it is
* `is_closed` in `ticketing_statuses`, so both the column and every count
* that reads `$closedSlugs` already understand it.
*/
public const STATUS_UNMERGED = 'closed';
/**
* What every surface says when a write to a merged ticket is refused. The Show
* page has displayed this sentence since merge shipped; #920 was that it was
* only ever a sentence, with no server-side guard behind it.
*/
public const READ_ONLY_REASON = 'This ticket has been merged and is read-only.';
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',
];
// `date:Y-m-d`, not bare `date`. The column is a DATE and every UI that shows
// it is an <input type="date">, which accepts exactly `Y-m-d` and silently
// renders anything else as an EMPTY field. A bare `date` cast serializes to
// `2026-09-15T00:00:00.000000Z`, so a due date that had been stored perfectly
// well came back as a blank input on the edit form, the show page and the
// index pane — indistinguishable from "it was never saved", which is how this
// was reported (a QA sweep read `input.value === ''` as silent data loss).
//
// The write path was never at fault: the column, the `$fillable` entry and
// both `validate()` rules were correct throughout, and re-submitting the ISO
// string (which Show.vue's meta form does, because it seeds itself from this
// value) still stores the right day — measured in UTC and in
// America/New_York. So nothing was ever lost in the database; the value
// simply had no way of reaching the screen.
/**
* `merged_inconsistent` rides on every serialized ticket — #938.
*
* The badge and its "Mark merged" button are rendered per ROW, on the index
* list, in the detail pane and on the Show header, so the page needs the
* answer for each ticket it draws. Handing it the model's own predicate
* rather than letting three Vue files each write
* `t.merged_into_id && t.status !== 'merged'` is what keeps the client's idea
* of "inconsistent" from drifting away from the scope the repair uses.
* It costs no query — both columns are already loaded.
*
* #1567 added `merged_stranded` beside it, for the same reason: there are now
* TWO ways a merge can fail to add up and they need different offers, so the
* page is handed which one it is rather than working it out from the columns.
*/
protected $appends = ['merged_inconsistent', 'merged_stranded'];
protected $casts = [
'due_date' => 'date',
'due_date' => 'date:Y-m-d',
];
public function group(): BelongsTo
@@ -41,4 +99,153 @@ 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');
}
/**
* A merged ticket is read-only — its messages and attachments now live on the
* ticket it was merged into, so it is a stub kept for its reference number and
* the audit trail, not a ticket anyone can still work.
*
* `status === 'merged'` and NOT `merged_into_id !== null`, deliberately. #920
* minted rows with `status = 'open', merged_into_id = 56` — the very rows this
* guard exists to prevent — and reading the id would make each of those
* permanently uneditable, i.e. a row the product will not let anyone correct.
* The status is the state; the id is the pointer that goes with it.
*/
public function isMerged(): bool
{
return $this->status === self::STATUS_MERGED;
}
/**
* THE SHAPE THE MERGE BUG MINTED — [central-ticketing] #938.
*
* `merged_into_id` points at a target while `status` is something other than
* `merged`: the row was merged, and then a save through the unguarded Edit
* form (#920) put the status back. #920 stopped new ones being made and
* deliberately left the existing ones EDITABLE — `isMerged()` reads the
* status, not the pointer — so that the office can correct one rather than
* needing SQL. This is the predicate that finds them.
*
* It is one definition and three callers read it: the badge that offers the
* fix, the `mark-merged` verb that applies it one row at a time, and the
* repair migration that applies it to all of them at once. Two spellings of
* "inconsistent" that drifted apart would mean a badge on a row the repair
* skips.
*/
public function scopeInconsistentlyMerged(Builder $query): Builder
{
return $query->whereNotNull('merged_into_id')
->where('status', '!=', self::STATUS_MERGED);
}
/** The row-level twin of {@see scopeInconsistentlyMerged()}. */
public function isInconsistentlyMerged(): bool
{
return $this->merged_into_id !== null && ! $this->isMerged();
}
/**
* THE OTHER DIRECTION — [central-ticketing] #1567.
*
* `status = 'merged'` with NOTHING to point at. `merged_into_id` carries
* `nullOnDelete()`, so deleting the ticket that others had been merged INTO
* used to blank their pointer and leave the status behind: a row that
* {@see isMerged()} still calls read-only, whose banner names a ticket that
* no longer exists, and which the Merged view — scoped by the pointer — could
* not list. Nothing in the product could act on it.
*
* `destroy()` no longer mints these ({@see \Dashboard\Ticketing\Support\StrandedMerge}),
* but the rows already made are still out there, so the shape needs a name:
* this is what the badge offers to un-merge and what the Merged view now
* includes.
*
* Deliberately NOT folded into {@see scopeInconsistentlyMerged()}: that scope
* is what #938's repair writes `status = 'merged'` over, and a stranded row
* already HAS that status — repairing it would match forever and write
* nothing, which is exactly the idempotency the repair guarantees.
*/
public function scopeStrandedMerge(Builder $query): Builder
{
return $query->where('status', self::STATUS_MERGED)
->whereNull('merged_into_id');
}
/** The row-level twin of {@see scopeStrandedMerge()}. */
public function isStrandedMerge(): bool
{
return $this->isMerged() && $this->merged_into_id === null;
}
/**
* EITHER shape of a merge that does not add up — #938's and #1567's.
*
* The two are mutually exclusive by construction (one has a pointer and the
* wrong status, the other has the right status and no pointer), and the
* badge, the sidebar's warning count and `merged_inconsistent` all want the
* union: "this row's status and its pointer disagree, whichever way round".
* The repair scopes stay separate because the fixes differ.
*/
public function scopeMergeInconsistent(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->where(fn (Builder $i) => $i->inconsistentlyMerged())
->orWhere(fn (Builder $s) => $s->strandedMerge());
});
}
/**
* Every row that carries the merge shape AT ALL — what the Merged view lists.
*
* Pointer set (whatever the status: #938's rows included) or status `merged`
* (whether or not the pointer survived: #1567's stranded rows included).
* Scoping the view by the pointer alone dropped a stranded row out of the one
* view whose whole job is showing merges, leaving it visible only under
* Resolved — so a reader looking for merged tickets never found it.
*/
public function scopeEverMerged(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNotNull('merged_into_id')
->orWhere('status', self::STATUS_MERGED);
});
}
/**
* @see $appends — the same answer, for the page.
*
* #1567 widened this to the union: a stranded row is inconsistent in the
* plainest sense (it says it is merged into nothing), and the flag existing
* while reading `false` on exactly that row was half of what the card
* reported.
*/
public function getMergedInconsistentAttribute(): bool
{
return $this->isInconsistentlyMerged() || $this->isStrandedMerge();
}
/**
* @see $appends — WHICH of the two shapes, for a page that must offer
* different fixes. `merged_inconsistent` says something is wrong;
* `merged_stranded` says the target is gone, so the offer is "un-merge"
* rather than #938's "mark merged".
*/
public function getMergedStrandedAttribute(): bool
{
return $this->isStrandedMerge();
}
}

View File

@@ -5,9 +5,20 @@ namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
class TicketMessage extends Model
{
/**
* Every value `ticket_messages.source` accepts, written down once.
*
* The column is an enum and this is the same list, so the two can be compared
* rather than remembered. #415 was three `'source' => 'system'` literals against
* a column that only knew 'web' and 'email' — the value existed in the code for
* as long as merge and split have, and nowhere in the schema.
*/
public const SOURCES = ['web', 'email', 'system'];
protected $fillable = [
'ticket_id', 'user_id', 'author_email', 'body',
'is_internal', 'source', 'email_message_id',
@@ -17,6 +28,33 @@ class TicketMessage extends Model
'is_internal' => 'boolean',
];
/**
* THE ONLY WAY A SYSTEM NOTE IS WRITTEN.
*
* Merge writes one and split writes two, and before this they were three
* separate literals in one controller — which is how all three carried a
* `source` the column rejected, and how a fourth would have joined them.
*
* A system note is always internal: it is the audit trail of an agent action,
* addressed to whoever works the ticket next, and never to the submitter.
*/
public static function system(Ticket|int $ticket, string $body): self
{
return static::create([
'ticket_id' => $ticket instanceof Ticket ? $ticket->id : $ticket,
'user_id' => Auth::id(),
'body' => $body,
'is_internal' => true,
'source' => 'system',
]);
}
/** A note this class wrote, rather than a person or a mailbox. */
public function isSystem(): bool
{
return $this->source === 'system';
}
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_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,13 +2,45 @@
namespace Dashboard\Ticketing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\DB;
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');
@@ -34,15 +66,59 @@ class TicketingGroup extends Model
return $this->hasMany(EmailConnection::class, 'group_id');
}
/**
* Issue the next ticket reference for this group.
*
* This used to be MAX(suffix) over the group's live tickets. Tickets are
* hard-deleted, so that made the reference a *reusable* handle: delete the
* newest ticket and its number went back in the pool, and the next ticket was
* issued the same one. Two tickets carrying `QAX-0004` is not a cosmetic
* problem — the reference is the `[QAX-0004]` token in outbound mail subjects
* (so a reply threads onto the wrong ticket), the key the shell Notification
* Log is audited by, and what `merge()` resolves a ticket by.
*
* The number now comes from `last_ticket_number`, a per-group high-water mark
* that is only ever raised. Deleting a ticket cannot lower it, because it does
* not consult the tickets table for the answer.
*
* Two details that are not decoration:
*
* - `lockForUpdate` inside a transaction. Read-then-write is a race: two people
* submitting at the same instant would otherwise both read N and both be
* issued N+1, reintroducing exactly the duplicate this method exists to
* prevent. The row lock serialises them.
* - The counter is raised to clear any live ticket that is already numbered
* past it before being incremented. Not every row gets its number from here —
* `TicketingDemoSeeder` writes them directly, and imports and manual repairs
* can too — and a counter that has fallen behind such a row would issue a
* number that is already in use. Taking the max of the two can only ever move
* the counter forwards, so this preserves the never-goes-backwards property
* rather than weakening it.
*/
public function nextTicketNumber(): string
{
$prefix = $this->prefix . '-';
$last = $this->tickets()
->where('number', 'like', $prefix . '%')
->orderByRaw('CAST(SUBSTRING(number, ?) AS UNSIGNED) DESC', [strlen($prefix) + 1])
->value('number');
$next = $last ? ((int) substr($last, strlen($prefix))) + 1 : 1;
$next = DB::transaction(function () use ($prefix) {
$counter = (int) static::query()
->whereKey($this->getKey())
->lockForUpdate()
->value('last_ticket_number');
$liveMax = (int) $this->tickets()
->where('number', 'like', $prefix . '%')
->max(DB::raw('CAST(SUBSTRING(number, ' . (strlen($prefix) + 1) . ') AS UNSIGNED)'));
$next = max($counter, $liveMax) + 1;
// Query-builder update, so `last_ticket_number` need not be fillable —
// it is a sequence, never something a form should be able to set.
static::query()->whereKey($this->getKey())->update(['last_ticket_number' => $next]);
$this->setAttribute('last_ticket_number', $next);
return $next;
});
return $prefix . str_pad($next, 4, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Dashboard\Ticketing\Support;
use App\Models\Setting;
use Dashboard\Ticketing\Models\Ticket;
use Illuminate\Support\Facades\Log;
/**
* REPAIRING THE ROWS THE MERGE BUG MINTED — [central-ticketing] #938.
*
* #920 stopped the bug making new ones: a ticket whose `status` is `merged` is
* read-only server-side, so the Edit form can no longer write `status = open`
* over a row that has already been folded into another ticket. It shipped NO
* data change, on purpose, and its guard reads the status rather than the
* pointer precisely so the rows already carrying the corrupt shape
* (`status = open, merged_into_id = 56`) stay editable and can be corrected
* through the UI instead of by hand in SQL.
*
* This class is the correction, and it is deliberately ONE implementation with
* three callers, because "which rows are broken" and "what fixing one means"
* are the two things that must not drift:
*
* - {@see \Dashboard\Ticketing\Http\Controllers\TicketController::markMerged()}
* — the office fixes one row, from the badge on the ticket header.
* - {@see \Dashboard\Ticketing\Console\Commands\RepairMergedTickets}
* — `--dry-run` is the COUNT, and it is the whole reason the class is not
* just a line inside the migration: the operator has to be able to see how
* many rows there are, and which, before anything writes.
* - the `2026_09_04_000001_repair_inconsistent_merged_tickets` migration
* — every row at once, on deploy, and ONLY when the operator has turned
* {@see SETTING} on.
*
* ── WHAT REPAIRING DOES *NOT* DO ────────────────────────────────────────────
*
* It writes `status = 'merged'` and nothing else. Messages, participants,
* assignee, priority and title are left exactly as they are, including edits
* made to the row AFTER the merge — those are somebody's work, they are the
* only copy, and there is no record of what the fields held before the bad
* save to revert them to. The ticket becoming read-only is the whole change.
*/
class MergedTicketRepair
{
/**
* "Auto-repair inconsistent merged tickets on deploy", and it DEFAULTS OFF.
*
* A deploy that silently rewrites rows nobody asked it to rewrite is the one
* outcome #938 rules out by name, so the migration reads this and no-ops
* when it is unset. Off is therefore the whole behaviour of a box that has
* never heard of this setting, which is what makes shipping the migration
* safe before the operator has decided anything.
*/
public const SETTING = 'ticketing.auto_repair_merged_on_deploy';
/** How many rows are inconsistent right now. The dry run, and it writes nothing. */
public static function count(): int
{
return Ticket::query()->inconsistentlyMerged()->count();
}
/**
* The rows themselves, for a human to look at before deciding.
*
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
*/
public static function rows()
{
return Ticket::query()->inconsistentlyMerged()->orderBy('id')->get();
}
/** Has the operator asked for the deploy-time repair? */
public static function enabledOnDeploy(): bool
{
return filter_var(Setting::get(self::SETTING, '0'), FILTER_VALIDATE_BOOLEAN);
}
/**
* Re-mark every inconsistent row as merged. Returns how many rows changed.
*
* IDEMPOTENT, and it is the scope that makes it so rather than a flag: the
* second run matches nothing, because the first run's rows no longer satisfy
* `status <> 'merged'`. So a migration that is re-run, a command run twice
* and a deploy that repeats all answer 0 and write nothing.
*/
public static function repairAll(string $by): int
{
$numbers = Ticket::query()->inconsistentlyMerged()->pluck('number')->all();
if ($numbers === []) {
Log::info("[ticketing] #938 merged-ticket repair ({$by}): nothing to repair.");
return 0;
}
$changed = Ticket::query()->inconsistentlyMerged()->update([
'status' => Ticket::STATUS_MERGED,
]);
// The numbers, not just the count. These rows are being made read-only,
// and "which ones" is the question the office will ask afterwards.
Log::info(sprintf(
'[ticketing] #938 merged-ticket repair (%s): re-marked %d ticket(s) as merged — %s',
$by,
$changed,
implode(', ', $numbers),
));
return $changed;
}
/**
* Re-mark ONE row. Returns false when it was not inconsistent to begin with,
* so a double-clicked button and a stale page are both no-ops rather than a
* second write.
*/
public static function repair(Ticket $ticket, string $by): bool
{
if (! $ticket->isInconsistentlyMerged()) {
return false;
}
$ticket->forceFill(['status' => Ticket::STATUS_MERGED])->save();
Log::info(sprintf(
'[ticketing] #938 merged-ticket repair (%s): re-marked %s as merged (merged into #%d).',
$by,
$ticket->number,
$ticket->merged_into_id,
));
return true;
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace Dashboard\Ticketing\Support;
use Dashboard\Ticketing\Models\Ticket;
use Dashboard\Ticketing\Models\TicketMessage;
use Illuminate\Support\Facades\Log;
/**
* DELETING A MERGE TARGET MUST NOT STRAND WHAT WAS MERGED INTO IT — #1567.
*
* `tickets.merged_into_id` is declared `nullOnDelete()`, so deleting a ticket
* that other tickets had been merged INTO used to blank their pointer and walk
* away, leaving `status = 'merged'` behind with nothing on the other end:
*
* - {@see Ticket::isMerged()} still calls the row read-only, so the page drops
* the status / priority / assignee selects and the reply composer, and the
* only control left on it is Delete. Nothing can reopen it, reassign it or
* reply to it — ever.
* - the banner "This ticket has been merged and is read-only." names a merge
* target that no longer exists.
* - the Merged view was scoped by the pointer, so the row vanished from the one
* view whose job is showing merges and turned up only under Resolved.
*
* And `destroy()` gave no hint any of this was about to happen: the confirm was
* the generic "This cannot be undone."
*
* ── WHAT THIS DOES INSTEAD ──────────────────────────────────────────────────
*
* {@see handleTargetDeletion()} runs inside `destroy()`'s transaction, BEFORE the
* row goes, and gives every ticket pointing at it somewhere real to be:
*
* RE-POINT, when the doomed ticket was itself merged into a third ticket. The
* messages genuinely did travel that far — merge moves them to the target, so
* merging the target onward moved them again — and the surviving ticket at the
* end of the chain really is where this ticket's content lives. This is the
* answer that keeps the merge true.
*
* UN-MERGE, otherwise. There is nowhere left to point, so the row stops
* claiming to be merged: {@see Ticket::STATUS_UNMERGED} (`closed`) and a NULL
* pointer, which is an ordinary closed ticket — out of the work queues, but
* editable, reopenable and answerable again.
*
* Either way the source gets a system note saying which ticket was deleted and
* what happened to it, because the row is being changed by somebody who was
* looking at a different ticket entirely and its own history is the only place
* that will ever explain it.
*
* ── THE ROWS ALREADY OUT THERE ──────────────────────────────────────────────
*
* This stops new ones. The stranded rows prod already holds are found by
* {@see Ticket::scopeStrandedMerge()}, now report `merged_inconsistent`, are
* listed by the Merged view, and are released one at a time by
* {@see \Dashboard\Ticketing\Http\Controllers\TicketController::unmerge()} —
* which calls {@see release()} here, so "un-merged" means one thing whichever
* door it came through.
*
* Deliberately NOT part of {@see MergedTicketRepair}: that class repairs #938's
* shape by writing `status = 'merged'` onto a row that has a target. This is the
* opposite operation on the opposite shape, and the two must not share a scope.
*/
class StrandedMerge
{
/** How many rows are stranded right now — status `merged`, pointer NULL. */
public static function count(): int
{
return Ticket::query()->strandedMerge()->count();
}
/**
* The stranded rows themselves.
*
* @return \Illuminate\Database\Eloquent\Collection<int,Ticket>
*/
public static function rows()
{
return Ticket::query()->strandedMerge()->orderBy('id')->get();
}
/**
* Give every ticket merged into `$target` somewhere real to be, before
* `$target` is deleted. Call inside the deleting transaction.
*
* Group membership is NOT re-checked per source. A source can live in another
* group, and the alternative to touching it is leaving it permanently frozen
* — this is the delete's own clean-up, not a write the operator chose to make
* on that ticket.
*
* @return array{repointed: list<string>, unmerged: list<string>, cleared: list<string>}
* ticket numbers, by what happened to them
*/
public static function handleTargetDeletion(Ticket $target, string $by): array
{
$sources = Ticket::where('merged_into_id', $target->id)->get();
$result = ['repointed' => [], 'unmerged' => [], 'cleared' => []];
if ($sources->isEmpty()) {
return $result;
}
// Where the content actually went. `merged_into_id` is nulled when ITS
// target is deleted, so this is either a live ticket or nothing.
$chain = $target->merged_into_id;
foreach ($sources as $source) {
// #938's shape: the pointer is stale but the status is live, so the
// row is an ordinary working ticket that happens to carry a dangling
// id. Dropping the id is the whole fix — and it is what the FK was
// about to do anyway; doing it here means the row is consistent
// inside this transaction rather than as a side effect afterwards.
if (! $source->isMerged()) {
$source->forceFill(['merged_into_id' => null])->save();
$result['cleared'][] = $source->number;
continue;
}
// `$chain === $source->id` cannot be produced by `merge()` (it
// refuses a merged source and a self-merge), but a cycle would make
// the row point at itself, which is worse than un-merging it.
if ($chain !== null && $chain !== $source->id) {
$chainTicket = Ticket::find($chain);
if ($chainTicket) {
$source->forceFill(['merged_into_id' => $chainTicket->id])->save();
TicketMessage::system($source, sprintf(
'Ticket %s was deleted. This ticket had been merged into it, and %s '
.'had itself been merged into %s — where its messages now live — so it '
.'is recorded as merged into %s.',
$target->number,
$target->number,
$chainTicket->number,
$chainTicket->number,
));
$result['repointed'][] = $source->number;
continue;
}
}
self::release(
$source,
sprintf(
'Ticket %s, which this ticket had been merged into, was deleted. This '
.'ticket has been un-merged so that it is no longer read-only; its status '
.'is now %s.',
$target->number,
Ticket::STATUS_UNMERGED,
),
$by,
);
$result['unmerged'][] = $source->number;
}
Log::info(sprintf(
'[ticketing] #1567 deleting %s: re-pointed %d, un-merged %d, cleared %d dangling pointer(s) (%s).',
$target->number,
count($result['repointed']),
count($result['unmerged']),
count($result['cleared']),
$by,
));
return $result;
}
/**
* Un-merge ONE ticket: status to {@see Ticket::STATUS_UNMERGED}, pointer to
* NULL, and a system note saying why.
*
* `forceFill`, not `update([...])`, for the same reason the rest of the merge
* code uses it: this is the machinery correcting its own state, and it must
* not be routed through anything that treats `merged` as read-only.
*
* Returns false when the row was not merged at all, so a double-clicked
* button and a stale page are no-ops rather than a second write.
*/
public static function release(Ticket $ticket, string $reason, string $by): bool
{
if (! $ticket->isMerged()) {
return false;
}
$ticket->forceFill([
'status' => Ticket::STATUS_UNMERGED,
'merged_into_id' => null,
])->save();
TicketMessage::system($ticket, $reason);
Log::info(sprintf(
'[ticketing] #1567 un-merged %s to %s (%s).',
$ticket->number,
Ticket::STATUS_UNMERGED,
$by,
));
return true;
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Dashboard\Ticketing\Support;
use App\Models\User;
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.
*
* PASS THE PERSON, NOT THE ADDRESS, whenever we have one.
*
* This took a bare string, and `Notify::sendAll()` derives everything
* user-shaped from the recipient it is handed: `$user = $recipient instanceof
* User ? $recipient : null`. With a string there is no `$user`, so three
* things silently went missing at once, all from the same line:
*
* - `to_user_id` was null, so the send never appeared in that person's
* notification history;
* - `to_name` was null, so the shared Notification Log fell through to its
* "we don't know who this went to" rendering and printed the message TYPE
* where the recipient's name belongs — for people we plainly do know, since
* both our notifications go to a Central user by definition (a ticket's
* submitter, or the agent it was just assigned to);
* - the preference branch is guarded by `if ($user && ! $mandatory && …)`, so
* it was skipped entirely and **the opt-out did nothing**. Neither of our
* categories is mandatory, so My Notifications offered a switch that was
* never read.
*
* A bare string is still accepted, for a future send to an address with no
* account behind it. It is simply no longer what our own callers hand over.
*
* Note we no longer resolve the address by hand either: `Notify` resolves a
* User school → personal → login, which is the platform's answer to "where do
* we email this person" and is what every other app gets.
*
* @param User|string $to the recipient — a User wherever one is known
* @return array{handled:bool,status:?string,error:?string,id:?int}
*/
public static function deliver(
string $category,
User|string $to,
string $subject,
string $bodyHtml,
?Model $context = null,
): array {
$unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null];
if (! self::available() || (is_string($to) && 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,28 @@ 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,
\Dashboard\Ticketing\Console\Commands\RepairMergedTickets::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,16 @@ 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');
// #938 — the one-click fix for a row the merge bug left inconsistent.
Route::post('/{ticket}/mark-merged', [TicketController::class, 'markMerged']) ->name('mark-merged');
// #1567 — the one-click release for a row whose merge target was deleted.
Route::post('/{ticket}/unmerge', [TicketController::class, 'unmerge']) ->name('unmerge');
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 () {