21 Commits

Author SHA1 Message Date
josh 2d9464a6fb Add clear selection button to status multi-select dropdown
Build & Push / Test (client) (push) Successful in 25s
Build & Push / Test (server) (push) Successful in 30s
Build & Push / Build Client (push) Successful in 59s
Build & Push / Build Server (push) Successful in 1m10s
Shows a separator and centered "Clear selection" at the bottom of the dropdown
when any statuses are selected. Clearing shows all tickets regardless of status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 12:25:53 -04:00
josh c6ec47a8fc Replace status tabs with multi-select checkbox dropdown, default to Open + In Progress
Build & Push / Test (client) (push) Successful in 29s
Build & Push / Test (server) (push) Successful in 26s
Build & Push / Build Client (push) Successful in 1m9s
Build & Push / Build Server (push) Successful in 1m17s
Status filtering now supports selecting multiple statuses via a dropdown with checkboxes.
Backend updated to accept comma-separated status values using Prisma `in` operator.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:52:13 -04:00
josh cfe7ad56ff Rework tickets filter bar into two-row layout with consistent CTI styling
Build & Push / Test (client) (push) Successful in 27s
Build & Push / Test (server) (push) Successful in 31s
Build & Push / Build Client (push) Successful in 1m5s
Build & Push / Build Server (push) Successful in 1m43s
Split the dense single-row filter bar into two rows: search + saved views on top,
filter selectors below. Fix CTI selectors to use design system tokens instead of
hardcoded dark classes, and upgrade the saved views button with an icon and badge count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:20:29 -04:00
josh 2177162300 Config and housekeeping cleanup
Build & Push / Test (client) (push) Successful in 29s
Build & Push / Test (server) (push) Successful in 28s
Build & Push / Build Client (push) Successful in 53s
Build & Push / Build Server (push) Successful in 2m21s
- .gitignore: add coverage/, .vscode/, .idea/
- .env.example files: add header comments clarifying production vs dev,
  add SMTP vars to server dev template
- Validate SavedView filters on load with safeParse fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:45:46 -04:00
josh 7f50783600 Split Tickets.tsx (631 lines) into focused sub-components
Extracted TicketFilters, BulkActions, and TicketListItem into
client/src/pages/tickets/. The main Tickets.tsx remains as the
page orchestrator with state management and pagination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:44:21 -04:00
josh 6c93a8c466 Split TicketDetail.tsx (775 lines) into focused sub-components
Extracted TicketComments, TicketAuditLog, and TicketSidebar into
client/src/pages/ticket-detail/. The main TicketDetail.tsx remains
as the page orchestrator. Router import unchanged via index.ts
re-export.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:41:27 -04:00
josh c0ff063023 Type mutation inputs with shared Zod schemas instead of Record<string, unknown>
Replaced loose Record<string, unknown> types on useCreateTicket,
useUpdateTicket, useCreateUser, useUpdateUser, useUpdateWebhook,
and useCreateSavedView with their corresponding shared schema types.
Fixed three type errors this surfaced at call sites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:37:23 -04:00
josh 86399c4ed0 Move status and audit label constants to shared/constants/labels
STATUS_LABELS was defined in the server, AUDIT_LABELS and AUDIT_COLORS
in the client. Both layers now import from a single shared source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:34:33 -04:00
josh d3ec27e223 Consolidate ticket ID/displayId lookup into shared where helper
Extracted idOrDisplayWhere() to eliminate the duplicated OR pattern
in getTicket, updateTicket, and commentService.addComment. The
exported findByIdOrDisplay() now uses it too.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:33:33 -04:00
josh b32e1dfa57 Extract cleanParams helper to deduplicate query param cleaning
The identical strip-undefined-values loop was duplicated in useTickets
and useTicketsPaged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:31:26 -04:00
josh 104f7773ba Extract severity color map into shared module
Severity-to-color mapping was duplicated in SeverityBadge, Tickets,
and MyTickets. Consolidated into lib/severityColors.ts with both
solid-bg (for stripes) and badge-style (for badges) variants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:30:51 -04:00
josh 28274bf7bd Add missing database indexes for FK lookups and audit queries
Type(categoryId), Item(typeId), Attachment(uploadedById), and
AuditLog(ticketId, createdAt) were missing indexes for their
primary query patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:25:40 -04:00
josh 5acc252921 Add requireAgent guard to analytics and export routes
Both endpoints were authenticated but had no role check, allowing any
logged-in USER to view company-wide analytics and export all tickets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 20:25:19 -04:00
josh a9bf332369 Retheme UI from blue to neutral zinc backgrounds with indigo accents
Build & Push / Test (client) (push) Successful in 33s
Build & Push / Test (server) (push) Successful in 25s
Build & Push / Build Client (push) Successful in 42s
Build & Push / Build Server (push) Successful in 1m5s
Removes the blue tint from all dark-mode surfaces by switching CSS
variables to zinc-based neutrals, and replaces decorative blue classes
with indigo across buttons, focus rings, tabs, and links. Semantic blue
(severity badges, status badges, role badges, timeline markers) is
preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 13:29:50 -04:00
josh f98930b54f Unblock prod deploy from Prisma data-loss guard
Build & Push / Test (client) (push) Successful in 32s
Build & Push / Test (server) (push) Successful in 31s
Build & Push / Build Client (push) Successful in 1m9s
Build & Push / Build Server (push) Successful in 2m17s
db push now runs with --accept-data-loss so the SERVICE enum-value
removal (rows already migrated by pre-push.sql) doesn't halt the boot.
Both Ticket and Comment also declare searchVector as
Unsupported("tsvector") so Prisma stops proposing to drop the columns
that post-push.sql manages — after this deploy, --accept-data-loss
becomes belt-and-suspenders rather than routinely required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:56:00 -04:00
josh f7028c563a Let Dashboard and wide Layout spread on 2xl screens
Build & Push / Test (client) (push) Successful in 26s
Build & Push / Test (server) (push) Successful in 29s
Build & Push / Build Client (push) Successful in 51s
Build & Push / Build Server (push) Successful in 1m38s
Dashboard now opts into `wide`, and the wide container scales from 1400
to 1800px at the 2xl breakpoint so content uses the extra room on big
monitors. Queue-load grid gains xl/2xl column counts for the new width.
Below 1536px nothing changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 21:28:44 -04:00
josh d8785a964d Merge SERVICE role into AGENT
Build & Push / Test (client) (push) Successful in 31s
Build & Push / Test (server) (push) Successful in 38s
Build & Push / Build Client (push) Successful in 1m17s
Build & Push / Build Server (push) Successful in 1m18s
Every AGENT now gets an auto-generated API key on creation, shown once
in a modal. AGENTs log in with password and authenticate to the API
with X-Api-Key. pre-push.sql defensively migrates any residual SERVICE
rows to AGENT before Prisma rewrites the enum. Goddard is no longer
baked into the seed — create agents via Admin → Users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 22:44:32 -04:00
josh a9ba74f1af Remove login rate limiter (internal service only)
Build & Push / Test (client) (push) Successful in 22s
Build & Push / Test (server) (push) Successful in 28s
Build & Push / Build Client (push) Successful in 1m18s
Build & Push / Build Server (push) Successful in 1m18s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 17:43:10 -04:00
josh b341c64b02 Install root deps in Docker build so shared schemas resolve zod
Build & Push / Test (client) (push) Successful in 21s
Build & Push / Test (server) (push) Successful in 35s
Build & Push / Build Client (push) Successful in 1m12s
Build & Push / Build Server (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 17:19:45 -04:00
josh 2c11d19f76 Pin vitest-mock-extended to 2.x to match vitest 2.x peer
Build & Push / Test (client) (push) Successful in 23s
Build & Push / Test (server) (push) Successful in 29s
Build & Push / Build Client (push) Failing after 1m9s
Build & Push / Build Server (push) Failing after 35s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 17:14:40 -04:00
josh 186dcc4686 CI: npm ci at root so shared schemas resolve zod
Build & Push / Test (client) (push) Successful in 21s
Build & Push / Test (server) (push) Failing after 20s
Build & Push / Build Server (push) Has been skipped
Build & Push / Build Client (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 17:06:42 -04:00
54 changed files with 1982 additions and 1733 deletions
+2
View File
@@ -1,3 +1,5 @@
# Production — used with docker-compose (see server/.env.example for local dev)
# ── Registry ──────────────────────────────────────────────────────────────────
# Hostname of your container registry (no trailing slash)
REGISTRY=gitea.thewrightserver.net
+6
View File
@@ -22,6 +22,9 @@ jobs:
with:
node-version: '22'
- name: Install root dependencies
run: npm ci
- name: Install dependencies
run: npm ci
working-directory: ./client
@@ -46,6 +49,9 @@ jobs:
with:
node-version: '22'
- name: Install root dependencies
run: npm ci
- name: Install dependencies
run: npm ci
working-directory: ./server
+7
View File
@@ -15,5 +15,12 @@ dist/
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Test coverage
coverage/
# Claude Code
.claude/
+23 -24
View File
@@ -19,21 +19,20 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac
- **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help
- **PWA** — installable on mobile, offline app shell
- **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
- **Roles** — Admin, Agent, User, Service (API key auth for automation)
- **Roles** — Admin, Agent, User
- **Audit log** — every action tracked with actor, timestamp, and expandable detail
- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI
- **n8n ready** — service accounts authenticate via `X-Api-Key` header
- **n8n ready** — every Agent gets an auto-generated API key for `X-Api-Key` header auth
---
## Roles
| Role | Access |
| ----------- | ---------------------------------------------------------------------------- |
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) |
| **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login |
| Role | Access |
| --------- | ----------------------------------------------------------------------------------------- |
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed). Logs in with password and can authenticate via `X-Api-Key` header (key shown once at creation) |
| **User** | Basic access — view tickets and add comments only |
> Only **Admins** can manually set a ticket status to **Closed**.
@@ -99,7 +98,9 @@ docker compose exec server npm run db:seed
This creates:
- `admin` user (password: `admin123`) — **change this immediately**
- `goddard` service account — API key is printed to the console; copy it now
- Sample CTI hierarchy (categories, types, items)
Automation accounts are no longer seeded. Create an **Agent** via Admin → Users to get an API key for n8n / scripts — the key is shown once in a modal at creation time.
### Upgrading from v0.9
@@ -140,7 +141,7 @@ cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install
npm run db:push # creates tables + search indexes
npm run db:seed # seeds admin + Goddard + sample CTI
npm run db:seed # seeds admin user + sample CTI
npm run dev # http://localhost:3000
npm test # vitest (service layer)
npm run typecheck
@@ -165,12 +166,10 @@ CI runs typecheck + tests on both packages before building Docker images.
All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only)
- **API Key**: `X-Api-Key: sk_<key>` (on any Agent account)
Base URL: `https://tickets.thewrightserver.net/api`
`POST /api/auth/login` is rate-limited to 10 attempts per 15 minutes per IP.
---
### Authentication
@@ -236,7 +235,7 @@ List tickets, sorted by severity (ASC) then created date (DESC).
}
```
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and the Goddard integration.
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and API-key integrations.
---
@@ -248,7 +247,7 @@ Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes
#### `POST /api/tickets`
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
Create a new ticket. Requires **Agent** or **Admin** role.
**Body:**
@@ -270,7 +269,7 @@ Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
#### `PATCH /api/tickets/:id`
Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin**, or **Service** role.
Update a ticket. Accepts any combination of fields. Requires **Agent** or **Admin** role.
> Setting `status` to `CLOSED` requires **Admin** role.
@@ -470,12 +469,12 @@ Create a user.
"username": "string",
"email": "string",
"displayName": "string",
"password": "string (not required for SERVICE role)",
"role": "ADMIN | AGENT | USER | SERVICE"
"password": "string (min 8 chars)",
"role": "ADMIN | AGENT | USER"
}
```
Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again.
Agent accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. Use `PATCH /api/users/:id` with `{ "regenerateApiKey": true }` to rotate.
#### `PATCH /api/users/:id`
@@ -487,15 +486,15 @@ Delete a user. Cannot delete your own account.
---
## n8n Integration (Goddard)
## n8n Integration
The `goddard` service account authenticates via API key — no login flow needed.
Create an **Agent** account via Admin → Users. The API key is shown once in a modal at creation — copy it into n8n's credentials as the `X-Api-Key` header value. Every Agent can authenticate via both password (for the UI) and API key (for automation).
**Create a ticket from n8n:**
```
POST /api/tickets
X-Api-Key: sk_<goddard api key>
X-Api-Key: sk_<agent api key>
Content-Type: application/json
{
@@ -505,7 +504,7 @@ Content-Type: application/json
"categoryId": "<TheWrightServer category ID>",
"typeId": "<Automation type ID>",
"itemId": "<Backup item ID>",
"assigneeId": "<Goddard user ID>"
"assigneeId": "<agent user ID>"
}
```
@@ -515,7 +514,7 @@ CTI IDs can be fetched from:
- `GET /api/cti/types?categoryId=<id>`
- `GET /api/cti/items?typeId=<id>`
To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard.
To rotate an Agent's API key: Admin → Users → refresh icon next to the Agent. The old key stops working immediately.
---
+2 -1
View File
@@ -1,7 +1,8 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY client/package*.json ./client/
RUN cd client && npm ci
RUN npm ci --omit=dev && cd client && npm ci
COPY client ./client
COPY shared ./shared
RUN cd client && npm run build
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<meta name="theme-color" content="#4f46e5" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon.svg" />
+2 -2
View File
@@ -4,9 +4,9 @@ import PrivateRoute from './components/PrivateRoute';
import AdminRoute from './components/AdminRoute';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Tickets from './pages/Tickets';
import Tickets from './pages/tickets';
import MyTickets from './pages/MyTickets';
import TicketDetail from './pages/TicketDetail';
import TicketDetail from './pages/ticket-detail';
import Notifications from './pages/Notifications';
import Settings from './pages/Settings';
import AdminUsers from './pages/admin/Users';
+28 -14
View File
@@ -7,6 +7,11 @@ import type {
Webhook,
PaginatedResponse,
} from '../../../shared/types';
import type { CreateTicketInput, UpdateTicketInput } from '../../../shared/schemas/ticket';
import type { CreateUserInput, UpdateUserInput } from '../../../shared/schemas/user';
import type { UpdateWebhookInput } from '../../../shared/schemas/notification';
import type { CreateSavedViewInput } from '../../../shared/schemas/savedView';
import { savedViewFiltersSchema } from '../../../shared/schemas/savedView';
// ── Keys ─────────────────────────────────────────────────────────────────────
@@ -27,14 +32,20 @@ export const qk = {
analytics: (window: number) => ['analytics', window] as const,
};
// ── Tickets ─────────────────────────────────────────────────────────────────
// ── Helpers ─────────────────────────────────────────────────────────────────
export function useTickets(params: Record<string, string | number | undefined> = {}) {
// Strip undefined values for a stable key
function cleanParams(params: Record<string, string | number | undefined>): Record<string, string | number> {
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
return clean;
}
// ── Tickets ──────────────────────────────────────────────────────────────────
export function useTickets(params: Record<string, string | number | undefined> = {}) {
const clean = cleanParams(params);
return useQuery({
queryKey: qk.tickets(clean),
@@ -47,10 +58,7 @@ export function useTickets(params: Record<string, string | number | undefined> =
}
export function useTicketsPaged(params: Record<string, string | number | undefined> = {}) {
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
const clean = cleanParams(params);
return useQuery({
queryKey: qk.ticketsPaged(clean),
@@ -93,7 +101,7 @@ export function useTicketAudit(id: string | undefined, enabled = true) {
export function useCreateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
mutationFn: async (data: CreateTicketInput) =>
(await api.post<Ticket>('/tickets', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
@@ -104,7 +112,7 @@ export function useCreateTicket() {
export function useUpdateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
mutationFn: async ({ id, data }: { id: string; data: UpdateTicketInput }) =>
(await api.patch<Ticket>(`/tickets/${id}`, data)).data,
onSuccess: (ticket) => {
qc.setQueryData(qk.ticket(ticket.displayId), ticket);
@@ -262,7 +270,7 @@ export function useUsers() {
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
mutationFn: async (data: CreateUserInput) =>
(await api.post<User>('/users', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
@@ -271,7 +279,7 @@ export function useCreateUser() {
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
mutationFn: async ({ id, data }: { id: string; data: UpdateUserInput }) =>
(await api.patch<User>(`/users/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
@@ -341,7 +349,7 @@ export function useCreateWebhook() {
export function useUpdateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
mutationFn: async ({ id, data }: { id: string; data: UpdateWebhookInput }) =>
(await api.patch<Webhook>(`/webhooks/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
@@ -371,7 +379,13 @@ export function useRotateWebhookSecret() {
export function useSavedViews() {
return useQuery({
queryKey: qk.savedViews(),
queryFn: async () => (await api.get<SavedView[]>('/saved-views')).data,
queryFn: async () => {
const views = (await api.get<SavedView[]>('/saved-views')).data;
return views.map((v) => ({
...v,
filters: savedViewFiltersSchema.catch({}).parse(v.filters),
}));
},
staleTime: 60_000,
});
}
@@ -379,7 +393,7 @@ export function useSavedViews() {
export function useCreateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; filters: Record<string, unknown> }) =>
mutationFn: async (data: CreateSavedViewInput) =>
(await api.post<SavedView>('/saved-views', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
+52 -5
View File
@@ -4,9 +4,10 @@ interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string };
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
disabled?: boolean;
compact?: boolean;
}
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
export default function CTISelect({ value, onChange, disabled, compact }: CTISelectProps) {
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(value.categoryId || undefined);
const { data: items = [] } = useItems(value.typeId || undefined);
@@ -24,12 +25,58 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
};
const selectClass =
'block w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed';
'block w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
if (compact) {
return (
<div className="flex gap-2">
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
disabled={disabled}
className={selectClass}
>
<option value="">Category...</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
disabled={disabled || !value.categoryId}
className={selectClass}
>
<option value="">Type...</option>
{types.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
disabled={disabled || !value.typeId}
className={selectClass}
>
<option value="">Item...</option>
{items.map((i) => (
<option key={i.id} value={i.id}>
{i.name}
</option>
))}
</select>
</div>
);
}
return (
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Category</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Category</label>
<select
value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)}
@@ -46,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Type</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Type</label>
<select
value={value.typeId}
onChange={(e) => handleType(e.target.value)}
@@ -63,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div>
<div>
<label className="block text-xs font-medium text-gray-400 mb-1">Item</label>
<label className="block text-xs font-medium text-muted-foreground mb-1">Item</label>
<select
value={value.itemId}
onChange={(e) => handleItem(e.target.value)}
+3 -3
View File
@@ -112,7 +112,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
<div className="min-h-screen bg-background text-foreground flex flex-col">
{/* Top nav */}
<header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
<div className="mx-auto max-w-[1400px] px-4 h-12 flex items-center gap-4">
<div className="mx-auto max-w-[1400px] 2xl:max-w-[1800px] px-4 h-12 flex items-center gap-4">
<Link
to="/dashboard"
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
@@ -233,7 +233,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
{(title || action || subheader) && (
<div className="border-b border-border bg-card/50">
<div
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
className={`mx-auto px-4 py-3 ${wide ? 'max-w-[1400px] 2xl:max-w-[1800px]' : 'max-w-6xl'} flex items-center justify-between gap-3`}
>
<div className="min-w-0 flex-1">
{title && (
@@ -246,7 +246,7 @@ export default function Layout({ children, title, action, subheader, wide }: Lay
</div>
)}
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px]' : 'max-w-6xl'}`}>
<main className={`flex-1 mx-auto w-full px-4 py-6 ${wide ? 'max-w-[1400px] 2xl:max-w-[1800px]' : 'max-w-6xl'}`}>
{children}
</main>
+2 -8
View File
@@ -1,13 +1,7 @@
const config: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
};
import { SEVERITY_BADGE } from '../lib/severityColors';
export default function SeverityBadge({ severity }: { severity: number }) {
const { label, className } = config[severity] ?? config[5];
const { label, className } = SEVERITY_BADGE[severity] ?? SEVERITY_BADGE[5];
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}
+35 -35
View File
@@ -5,70 +5,70 @@
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 240 10% 3.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--primary: 263 70% 50.4%;
--primary-foreground: 0 0% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 0 0% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 263 70% 50.4%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: 263 70% 50.4%;
--primary-foreground: 0 0% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive-foreground: 0 0% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 263 70% 50.4%;
}
}
/* Native select dark option styling */
select option {
background-color: #1f2937;
background-color: #27272a;
color: #f3f4f6;
}
@@ -96,7 +96,7 @@ select option {
@apply mt-1 mb-0;
}
.prose a {
@apply text-blue-400 underline hover:text-blue-300;
@apply text-indigo-400 underline hover:text-indigo-300;
}
.prose strong {
@apply font-semibold;
+15
View File
@@ -0,0 +1,15 @@
export const SEVERITY_BG: Record<number, string> = {
1: 'bg-red-500',
2: 'bg-orange-400',
3: 'bg-yellow-400',
4: 'bg-blue-400',
5: 'bg-gray-600',
};
export const SEVERITY_BADGE: Record<number, { label: string; className: string }> = {
1: { label: 'SEV 1', className: 'bg-red-500/20 text-red-400 border-red-500/30' },
2: { label: 'SEV 2', className: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
3: { label: 'SEV 3', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
4: { label: 'SEV 4', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
5: { label: 'SEV 5', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
};
+2 -2
View File
@@ -45,7 +45,7 @@ export default function Dashboard() {
const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
return (
<Layout title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
<Layout wide title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
{!a ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading analytics</p>
) : (
@@ -143,7 +143,7 @@ export default function Dashboard() {
{a.queueByAssignee.length === 0 ? (
<p className="text-xs text-muted-foreground">No open tickets right now.</p>
) : (
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6">
{a.queueByAssignee
.slice()
.sort((x, y) => y.count - x.count)
+2 -2
View File
@@ -30,7 +30,7 @@ export default function Login() {
};
const inputClass =
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
@@ -70,7 +70,7 @@ export default function Login() {
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
+4 -13
View File
@@ -6,6 +6,7 @@ import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import { useTickets } from '../api/queries';
import { useAuth } from '../contexts/AuthContext';
import { SEVERITY_BG } from '../lib/severityColors';
export default function MyTickets() {
const { user } = useAuth();
@@ -39,20 +40,10 @@ export default function MyTickets() {
<Link
key={ticket.id}
to={`/${ticket.displayId}`}
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-blue-500/50 transition-all group"
className="flex items-center gap-4 bg-gray-900 border border-gray-800 rounded-lg px-4 py-3 hover:border-indigo-500/50 transition-all group"
>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${
ticket.severity === 1
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-600'
}`}
className={`w-1 self-stretch rounded-full flex-shrink-0 ${SEVERITY_BG[ticket.severity] ?? 'bg-gray-600'}`}
/>
<div className="flex-1 min-w-0">
@@ -66,7 +57,7 @@ export default function MyTickets() {
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</div>
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400">
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-indigo-400">
{ticket.title}
</p>
</div>
+11 -12
View File
@@ -38,9 +38,10 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
const onSubmit = async (data: CreateTicketInput) => {
setError('');
try {
const payload: Record<string, unknown> = { ...data };
if (!data.assigneeId) delete payload.assigneeId;
const created = await createTicket.mutateAsync(payload);
const { assigneeId, ...rest } = data;
const created = await createTicket.mutateAsync(
assigneeId ? { ...rest, assigneeId } : rest,
);
onClose();
navigate(`/${created.displayId}`);
} catch {
@@ -49,7 +50,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
};
const inputClass =
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
const errorClass = 'mt-1 text-xs text-red-400';
@@ -105,13 +106,11 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<label className={labelClass}>Assignee</label>
<select className={inputClass} {...register('assigneeId')}>
<option value="">Unassigned</option>
{users
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
</div>
</div>
@@ -164,7 +163,7 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'Creating...' : 'Create Ticket'}
</button>
-25
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import { useAuth } from '../contexts/AuthContext';
@@ -47,12 +46,6 @@ export default function Settings() {
}
};
const copyKey = async () => {
if (!user?.apiKey) return;
await navigator.clipboard.writeText(user.apiKey);
toast.success('API key copied');
};
return (
<Layout title="Settings">
<div className="space-y-6">
@@ -118,24 +111,6 @@ export default function Settings() {
</p>
</section>
{/* API key (service accounts only) */}
{user?.role === 'SERVICE' && user?.apiKey && (
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">API key</h2>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{user.apiKey}</span>
<button
onClick={copyKey}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Pass as <code>x-api-key</code> header on any server-to-server request.
</p>
</section>
)}
</div>
</Layout>
);
-796
View File
@@ -1,796 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Pencil,
Trash2,
Send,
X,
Check,
MessageSquare,
ClipboardList,
FileText,
ArrowLeft,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import Modal from '../components/Modal';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar';
import MentionTextarea from '../components/MentionTextarea';
import { injectMentionLinks } from '../lib/mentions';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicket,
useTicketAudit,
useUpdateTicket,
useDeleteTicket,
useUsers,
useAddComment,
useDeleteComment,
} from '../api/queries';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
type Tab = 'overview' | 'comments' | 'audit';
const SEVERITY_OPTIONS = [
{ value: 1, label: 'SEV 1 — Critical' },
{ value: 2, label: 'SEV 2 — High' },
{ value: 3, label: 'SEV 3 — Medium' },
{ value: 4, label: 'SEV 4 — Low' },
{ value: 5, label: 'SEV 5 — Minimal' },
];
const AUDIT_LABELS: Record<string, string> = {
CREATED: 'created this ticket',
STATUS_CHANGED: 'changed status',
ASSIGNEE_CHANGED: 'changed assignee',
SEVERITY_CHANGED: 'changed severity',
REROUTED: 'rerouted ticket',
TITLE_CHANGED: 'updated title',
OVERVIEW_CHANGED: 'updated overview',
COMMENT_ADDED: 'added a comment',
COMMENT_DELETED: 'deleted a comment',
};
const AUDIT_COLORS: Record<string, string> = {
CREATED: 'bg-green-500',
STATUS_CHANGED: 'bg-blue-500',
ASSIGNEE_CHANGED: 'bg-purple-500',
SEVERITY_CHANGED: 'bg-orange-500',
REROUTED: 'bg-cyan-500',
TITLE_CHANGED: 'bg-gray-500',
OVERVIEW_CHANGED: 'bg-gray-500',
COMMENT_ADDED: 'bg-gray-500',
COMMENT_DELETED: 'bg-red-500',
};
const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED']);
export default function TicketDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const [tab, setTab] = useState<Tab>('overview');
const [editing, setEditing] = useState(false);
const [reroutingCTI, setReroutingCTI] = useState(false);
const [commentBody, setCommentBody] = useState('');
const [preview, setPreview] = useState(false);
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const [deleteOpen, setDeleteOpen] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [editForm, setEditForm] = useState({ title: '', overview: '' });
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
const [statusOpen, setStatusOpen] = useState(false);
const [severityOpen, setSeverityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set());
// Draft autosave
const draftKey = id ? `comment-draft:${id}` : null;
useEffect(() => {
if (!draftKey) return;
const saved = localStorage.getItem(draftKey);
if (saved) setCommentBody(saved);
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (commentBody) localStorage.setItem(draftKey, commentBody);
else localStorage.removeItem(draftKey);
}, [commentBody, draftKey]);
const { data: ticket, isLoading } = useTicket(id);
const { data: users = [] } = useUsers();
const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit');
const updateTicket = useUpdateTicket();
const deleteTicketMutation = useDeleteTicket();
const addComment = useAddComment(id);
const deleteCommentMutation = useDeleteComment(id);
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleCommentDate = (commentId: string) =>
setExpandedCommentDates((prev) => {
const next = new Set(prev);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
const isAdmin = authUser?.role === 'ADMIN';
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return;
await updateTicket.mutateAsync({ id: ticket.displayId, data: payload });
};
const startEdit = () => {
if (!ticket) return;
setEditForm({ title: ticket.title, overview: ticket.overview });
setEditing(true);
setTab('overview');
};
const saveEdit = async () => {
await patch({ title: editForm.title, overview: editForm.overview });
setEditing(false);
};
const startReroute = () => {
if (!ticket) return;
setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId });
setReroutingCTI(true);
};
const saveReroute = async () => {
await patch(pendingCTI);
setReroutingCTI(false);
};
const confirmDeleteTicket = async () => {
if (!ticket) return;
await deleteTicketMutation.mutateAsync(ticket.displayId);
toast.success('Ticket deleted');
navigate('/tickets');
};
const submitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!ticket || !commentBody.trim()) return;
await addComment.mutateAsync(commentBody.trim());
setCommentBody('');
setPreview(false);
if (draftKey) localStorage.removeItem(draftKey);
};
const handleDeleteComment = async (commentId: string) => {
await deleteCommentMutation.mutateAsync(commentId);
};
useShortcut('e', (e) => {
if (!ticket || editing) return;
e.preventDefault();
startEdit();
}, [ticket, editing]);
useShortcut('r', (e) => {
if (!ticket) return;
e.preventDefault();
setTab('comments');
setPreview(false);
setTimeout(() => commentTextareaRef.current?.focus(), 40);
}, [ticket]);
const toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev);
if (next.has(logId)) next.delete(logId);
else next.add(logId);
return next;
});
};
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading...
</div>
</Layout>
);
}
if (!ticket) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found
</div>
</Layout>
);
}
const commentCount = ticket.comments?.length ?? 0;
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []),
];
return (
<Layout>
{/* Back link */}
<button
onClick={() => navigate('/tickets')}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
>
<ArrowLeft size={14} />
All tickets
</button>
<div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
{/* ── Main content ── */}
<div className="flex-1 min-w-0">
{/* Title card */}
<div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-500 ml-1">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
<button
onClick={startEdit}
className="ml-auto text-gray-500 hover:text-gray-300 transition-colors"
title="Edit title & overview"
>
<Pencil size={14} />
</button>
</div>
{editing ? (
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-blue-500 focus:outline-none pb-1"
autoFocus
/>
) : (
<h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)}
</div>
{/* Tabs + content */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
{/* Tab bar */}
<div className="flex border-b border-gray-800 px-2">
{(
[
{ key: 'overview', icon: FileText, label: 'Overview' },
{
key: 'comments',
icon: MessageSquare,
label: `Comments${commentCount > 0 ? ` (${commentCount})` : ''}`,
},
{ key: 'audit', icon: ClipboardList, label: 'Audit Log' },
] as const
).map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === key
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{/* ── Overview ── */}
{tab === 'overview' && (
<div className="p-6">
{editing ? (
<div className="space-y-3">
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={12}
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
<X size={13} /> Cancel
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Check size={13} /> Save changes
</button>
</div>
</div>
) : (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{ticket.overview}</ReactMarkdown>
</div>
)}
</div>
)}
{/* ── Comments ── */}
{tab === 'comments' && (
<div className="p-6 space-y-4">
{ticket.comments && ticket.comments.length > 0 ? (
ticket.comments.map((comment, i) => (
<div key={comment.id} className="flex gap-3 group">
{/* Avatar + spine */}
<div className="flex flex-col items-center">
<Avatar name={comment.author.displayName} size="md" />
{i < ticket.comments!.length - 1 && (
<div className="flex-1 w-px bg-gray-800 mt-2" />
)}
</div>
{/* Card */}
<div className="flex-1 min-w-0 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 border-b border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-200">
{comment.author.displayName}
</span>
<button
onClick={() => toggleCommentDate(comment.id)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{expandedCommentDates.has(comment.id)
? format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
})}
</button>
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => handleDeleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(comment.body, users)}
</ReactMarkdown>
</div>
</div>
</div>
))
) : (
<div className="py-12 text-center text-sm text-gray-600">No comments yet</div>
)}
{/* Composer */}
<div className="flex gap-3">
<Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex-1 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex gap-4 px-4 bg-gray-800/60 border-b border-gray-700">
{(['Write', 'Preview'] as const).map((label) => (
<button
key={label}
onClick={() => setPreview(label === 'Preview')}
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
(label === 'Preview') === preview
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{label}
</button>
))}
</div>
<form onSubmit={submitComment} className="p-3">
{preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(commentBody, users)}
</ReactMarkdown>
) : (
<span className="text-gray-600 italic">Nothing to preview</span>
)}
</div>
) : (
<div className="mb-3">
<MentionTextarea
ref={commentTextareaRef}
value={commentBody}
onChange={setCommentBody}
users={users}
onSubmit={() =>
submitComment({
preventDefault: () => {},
} as unknown as React.FormEvent)
}
placeholder="Leave a comment… Markdown & @mentions"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none"
/>
</div>
)}
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button
type="submit"
disabled={addComment.isPending || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />
Comment
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* ── Audit Log ── */}
{tab === 'audit' && (
<div className="p-6">
{auditLogs.length === 0 ? (
<div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
) : (
<div>
{auditLogs.map((log, i) => {
const hasDetail = !!log.detail;
const isExpanded = expandedLogs.has(log.id);
const isComment = COMMENT_ACTIONS.has(log.action);
return (
<div key={log.id} className="flex gap-4">
{/* Timeline */}
<div className="flex flex-col items-center w-5 flex-shrink-0">
<div
className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`}
/>
{i < auditLogs.length - 1 && (
<div className="w-px flex-1 bg-gray-800 my-1" />
)}
</div>
{/* Entry */}
<div className="flex-1 pb-4">
<div
className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
onClick={() => hasDetail && toggleLog(log.id)}
>
<p className="text-sm text-gray-300">
<span className="font-medium text-gray-100">
{log.user.displayName}
</span>{' '}
{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{hasDetail && (
<span className="ml-1 inline-flex items-center text-gray-600">
{isExpanded ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
</span>
)}
</p>
<span
className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
{hasDetail && isExpanded && (
<div className="mt-2 ml-0 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3">
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(log.detail!, users)}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Sidebar ── */}
<div className="w-full md:w-64 flex-shrink-0 md:sticky md:top-0 space-y-3">
{/* Ticket Summary */}
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Ticket Summary
</p>
</div>
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Status</p>
<StatusBadge status={ticket.status} />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value });
setStatusOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<StatusBadge status={s.value} />
{ticket.status === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
{!isAdmin && (
<p className="px-2 pt-1 text-[11px] text-muted-foreground">
Closing requires admin
</p>
)}
</PopoverContent>
</Popover>
{/* Severity */}
<Popover open={severityOpen} onOpenChange={setSeverityOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Severity</p>
<SeverityBadge severity={ticket.severity} />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value });
setSeverityOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<SeverityBadge severity={s.value} />
<span className="text-muted-foreground text-xs">
{s.label.split(' — ')[1]}
</span>
{ticket.severity === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</PopoverContent>
</Popover>
{/* CTI — one clickable unit */}
<button
onClick={startReroute}
className="w-full px-4 py-3 text-left space-y-3 hover:bg-gray-800/50 transition-colors"
>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Category</p>
<p className="text-sm text-gray-300">{ticket.category.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Type</p>
<p className="text-sm text-gray-300">{ticket.type.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Issue</p>
<p className="text-sm text-gray-300">{ticket.item.name}</p>
</div>
</button>
{/* Dates */}
<div className="px-4 py-3 space-y-2.5">
{[
{ key: 'created', label: 'Created', date: ticket.createdAt },
{ key: 'modified', label: 'Modified', date: ticket.updatedAt },
...(ticket.resolvedAt
? [{ key: 'resolved', label: 'Resolved', date: ticket.resolvedAt }]
: []),
].map(({ key, label, date }) => (
<div key={key}>
<p className="text-xs font-medium text-gray-500 mb-1">{label}</p>
<button
onClick={() => toggleDate(key)}
className="text-xs text-gray-300 hover:text-gray-100 transition-colors"
>
{expandedDates.has(key)
? format(new Date(date), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(date), { addSuffix: true })}
</button>
</div>
))}
</div>
{/* Assignee */}
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Assignee</p>
{ticket.assignee ? (
<div className="flex items-center gap-1.5">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.assignee.displayName}</span>
</div>
) : (
<p className="text-sm text-gray-500">Unassigned</p>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-1">
<button
onClick={async () => {
await patch({ assigneeId: null });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<span className="text-muted-foreground">Unassigned</span>
{!ticket.assigneeId && <Check size={13} className="ml-auto text-primary" />}
</button>
<div className="max-h-64 overflow-auto">
{agentUsers.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<Avatar name={u.displayName} size="sm" />
<span>{u.displayName}</span>
{ticket.assigneeId === u.id && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Requester */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-gray-500 mb-1.5">Requester</p>
<div className="flex items-center gap-1.5">
<Avatar name={ticket.createdBy.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.createdBy.displayName}</span>
</div>
</div>
</div>
{isAdmin && (
<div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<button
onClick={() => setDeleteOpen(true)}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 size={13} />
Delete ticket
</button>
</div>
)}
</div>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ticket?</AlertDialogTitle>
<AlertDialogDescription>
{ticket.displayId} · {ticket.title} will be permanently removed along with its
comments and audit log. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTicket}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{reroutingCTI && (
<Modal title="Change Routing" onClose={() => setReroutingCTI(false)} size="lg">
<div className="space-y-5">
<p className="text-sm text-gray-400">
Current:{' '}
<span className="text-gray-200">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</p>
<CTISelect value={pendingCTI} onChange={setPendingCTI} />
<div className="flex justify-end gap-3 pt-1">
<button
onClick={() => setReroutingCTI(false)}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={saveReroute}
disabled={!pendingCTI.itemId}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Save routing
</button>
</div>
</div>
</Modal>
)}
</Layout>
);
}
-641
View File
@@ -1,641 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../hooks/useShortcuts';
import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import Avatar from '../components/Avatar';
import CTISelect from '../components/CTISelect';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicketsPaged,
useBulkTickets,
useSavedViews,
useCreateSavedView,
useDeleteSavedView,
useUsers,
} from '../api/queries';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In progress' },
{ value: 'RESOLVED', label: 'Resolved' },
{ value: 'CLOSED', label: 'Closed' },
];
const PAGE_SIZE = 25;
const BULK_LIMIT = 500;
function sevColor(severity: number) {
return severity === 1
? 'bg-red-500'
: severity === 2
? 'bg-orange-400'
: severity === 3
? 'bg-yellow-400'
: severity === 4
? 'bg-blue-400'
: 'bg-gray-600';
}
export default function Tickets() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const status = (params.get('status') ?? '') as TicketStatus | '';
const severity = params.get('severity') ?? '';
const assigneeId = params.get('assigneeId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const typeId = params.get('typeId') ?? '';
const itemId = params.get('itemId') ?? '';
const search = params.get('search') ?? '';
const page = Math.max(1, Number(params.get('page') ?? '1'));
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
| { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }
| null
>(null);
useEffect(() => {
const t = setTimeout(() => {
if (search !== searchInput) {
updateParam('search', searchInput);
updateParam('page', '1');
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const updateParam = (key: string, value: string | null) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === null || value === '') next.delete(key);
else next.set(key, value);
if (key !== 'page') next.delete('page'); // reset page on filter change
return next;
},
{ replace: true },
);
};
const queryParams = {
status: status || undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
page,
pageSize: PAGE_SIZE,
};
const { data, isLoading, isFetching } = useTicketsPaged(queryParams);
const tickets = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const savedViewsQ = useSavedViews();
const createView = useCreateSavedView();
const deleteView = useDeleteSavedView();
const bulk = useBulkTickets();
const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id));
const someVisible = tickets.some((t) => selected.has(t.id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allVisible) tickets.forEach((t) => next.delete(t.id));
else tickets.forEach((t) => next.add(t.id));
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelected = () => setSelected(new Set());
const currentFilters = useMemo(
() => ({
status: status || undefined,
severity: severity || undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
}),
[status, severity, assigneeId, categoryId, typeId, itemId, search],
);
const runBulk = async (payload: { action: string; value?: unknown }) => {
const ids = Array.from(selected).slice(0, BULK_LIMIT);
try {
const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number };
toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`);
clearSelected();
} catch (e) {
toast.error((e as Error).message || 'Bulk action failed');
} finally {
setConfirmBulk(null);
}
};
const activeCount = Object.values(currentFilters).filter(Boolean).length;
const handleSaveView = async () => {
if (!newViewName.trim()) return;
try {
await createView.mutateAsync({
name: newViewName.trim(),
filters: currentFilters,
});
toast.success('Saved view');
setNewViewName('');
setSaveOpen(false);
} catch (e) {
toast.error((e as Error).message || 'Failed to save view');
}
};
const applyView = (filters: Record<string, unknown>) => {
const next = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') next.set(k, String(v));
});
setParams(next, { replace: true });
setSearchInput(String(filters.search ?? ''));
};
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return (
<Layout wide>
{/* Status tabs */}
<div className="flex items-center border-b border-border mb-4 -mx-4 px-4 overflow-x-auto">
{STATUS_TABS.map((tab) => (
<button
key={tab.value}
onClick={() => updateParam('status', tab.value || null)}
className={`px-3 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
status === tab.value
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Filter bar */}
<div className="flex gap-2 mb-4 flex-wrap items-center">
<input
type="search"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<select
value={severity}
onChange={(e) => updateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => updateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{agentUsers
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<div className="min-w-56">
<CTISelect
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
},
{ replace: true },
);
}}
/>
</div>
{/* Saved views */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-3 py-1.5 rounded-md border border-input bg-background text-sm hover:bg-accent">
Saved views
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedViewsQ.data && savedViewsQ.data.length > 0 ? (
savedViewsQ.data.map((v) => (
<div
key={v.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm"
>
<button
onClick={() => applyView(v.filters)}
className="flex-1 text-left text-sm truncate"
>
{v.name}
</button>
<button
onClick={() => deleteView.mutate(v.id)}
aria-label="Delete view"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">No saved views yet</p>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={activeCount === 0}
onSelect={() => setSaveOpen(true)}
className="gap-2"
>
<Save size={13} />
Save current filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto text-xs text-muted-foreground">
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div>
</div>
{/* Bulk bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 mb-3 px-3 py-2 rounded-md bg-accent border border-border">
<span className="text-sm font-medium">
{selected.size} selected
</span>
<button
onClick={clearSelected}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
<div className="h-4 w-px bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Reassign
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: null,
label: 'Unassign',
})
}
>
Unassigned
</DropdownMenuItem>
{agentUsers.map((u) => (
<DropdownMenuItem
key={u.id}
onSelect={() =>
setConfirmBulk({
kind: 'reassign',
value: u.id,
label: `Assign to ${u.displayName}`,
})
}
>
{u.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Severity
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[1, 2, 3, 4, 5].map((s) => (
<DropdownMenuItem
key={s}
onSelect={() =>
setConfirmBulk({
kind: 'setSeverity',
value: s,
label: `Set severity SEV ${s}`,
})
}
>
SEV {s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() =>
setConfirmBulk({ kind: 'close', label: 'Close selected tickets' })
}
className="px-2 py-1 rounded-md text-sm hover:bg-background"
>
Close
</button>
</div>
)}
{/* Ticket list */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground text-sm">Loading</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">No tickets found</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-card text-xs text-muted-foreground border-b border-border">
<input
type="checkbox"
checked={allVisible}
aria-label="Select all on page"
ref={(el) => {
if (el) el.indeterminate = !allVisible && someVisible;
}}
onChange={toggleAll}
className="cursor-pointer"
/>
<span>
{tickets.length} of {total}
</span>
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket, idx) => (
<li
key={ticket.id}
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
idx === cursor
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
>
<input
type="checkbox"
checked={selected.has(ticket.id)}
onChange={() => toggleOne(ticket.id)}
aria-label={`Select ${ticket.displayId}`}
className="cursor-pointer flex-shrink-0"
/>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${sevColor(ticket.severity)}`}
/>
<Link
to={`/${ticket.displayId}`}
className="flex-1 min-w-0 flex items-center gap-3 group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-sm font-medium text-foreground group-hover:text-primary truncate">
{ticket.title}
</span>
<span className="text-xs font-mono text-muted-foreground">
{ticket.displayId}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span>
opened {formatDistanceToNow(new Date(ticket.createdAt), {
addSuffix: true,
})}{' '}
by {ticket.createdBy.displayName}
</span>
<span className="hidden md:inline">
· {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
{ticket.assignee && (
<span className="hidden md:inline">· assigned {ticket.assignee.displayName}</span>
)}
<span>· {ticket._count?.comments ?? 0} comments</span>
</div>
</div>
<div className="hidden sm:flex items-center flex-shrink-0">
{ticket.assignee ? (
<Avatar name={ticket.assignee.displayName} size="sm" />
) : null}
</div>
</Link>
</li>
))}
</ul>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<div className="text-muted-foreground">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => updateParam('page', String(page - 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= totalPages}
onClick={() => updateParam('page', String(page + 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* Save view dialog */}
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save current filters</DialogTitle>
</DialogHeader>
<div className="py-2">
<input
autoFocus
type="text"
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
placeholder="View name"
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveView();
}}
/>
</div>
<DialogFooter>
<button
onClick={() => setSaveOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleSaveView}
disabled={!newViewName.trim() || createView.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm bulk action */}
<AlertDialog open={!!confirmBulk} onOpenChange={(o) => !o && setConfirmBulk(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmBulk?.label}</AlertDialogTitle>
<AlertDialogDescription>
This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!confirmBulk) return;
if (confirmBulk.kind === 'close') runBulk({ action: 'close' });
else
runBulk({
action: confirmBulk.kind,
value: confirmBulk.value,
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+6 -6
View File
@@ -131,7 +131,7 @@ export default function AdminCTI() {
const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
active
? 'bg-blue-600/20 border-l-2 border-blue-500'
? 'bg-indigo-600/20 border-l-2 border-indigo-500'
: 'hover:bg-gray-800 border-l-2 border-transparent'
}`;
@@ -144,7 +144,7 @@ export default function AdminCTI() {
<h3 className="text-sm font-semibold text-gray-300">Categories</h3>
<button
onClick={() => openAdd('category')}
className="text-blue-400 hover:text-blue-300 transition-colors"
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -199,7 +199,7 @@ export default function AdminCTI() {
{selectedCategory && (
<button
onClick={() => openAdd('type')}
className="text-blue-400 hover:text-blue-300 transition-colors"
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -257,7 +257,7 @@ export default function AdminCTI() {
{selectedType && (
<button
onClick={() => openAdd('item')}
className="text-blue-400 hover:text-blue-300 transition-colors"
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
<Plus size={16} />
</button>
@@ -314,7 +314,7 @@ export default function AdminCTI() {
onChange={(e) => setNameValue(e.target.value)}
required
autoFocus
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<div className="flex justify-end gap-3">
@@ -328,7 +328,7 @@ export default function AdminCTI() {
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : 'Save'}
</button>
+12 -16
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import { User, Role } from '../../types';
import type { CreateUserInput } from '../../../../shared/schemas/user';
import { useAuth } from '../../contexts/AuthContext';
import {
useUsers,
@@ -42,21 +43,18 @@ const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
USER: 'User',
SERVICE: 'Service',
};
const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
};
const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
AGENT: 'Manage tickets — create, update, assign, comment, change status',
AGENT: 'Manage tickets and automation — logs in with password and can authenticate via API key',
USER: 'Basic access — view tickets and add comments only',
SERVICE: 'Automation account — authenticates via API key, no password login',
};
export default function AdminUsers() {
@@ -108,14 +106,13 @@ export default function AdminUsers() {
e.preventDefault();
setError('');
try {
const payload: Record<string, string> = {
const created = await createUser.mutateAsync({
username: form.username,
email: form.email,
displayName: form.displayName,
role: form.role,
};
if (form.password) payload.password = form.password;
const created = await createUser.mutateAsync(payload);
role: form.role as CreateUserInput['role'],
password: form.password,
});
if (created.apiKey) setNewApiKey(created.apiKey);
else closeModal();
} catch (err: unknown) {
@@ -177,7 +174,7 @@ export default function AdminUsers() {
};
const inputClass =
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
'w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent';
const labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return (
@@ -186,7 +183,7 @@ export default function AdminUsers() {
action={
<button
onClick={openAdd}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
>
<Plus size={14} />
Add User
@@ -227,7 +224,7 @@ export default function AdminUsers() {
<td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
{u.role === 'AGENT' && (
<button
onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
@@ -284,7 +281,7 @@ export default function AdminUsers() {
</div>
<button
onClick={closeModal}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
className="w-full bg-indigo-600 text-white py-2 rounded-lg text-sm hover:bg-indigo-700 transition-colors"
>
Done
</button>
@@ -343,7 +340,7 @@ export default function AdminUsers() {
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add' && form.role !== 'SERVICE'}
required={modal === 'add'}
className={inputClass}
placeholder={modal === 'edit' ? '••••••••' : ''}
/>
@@ -359,7 +356,6 @@ export default function AdminUsers() {
<option value="AGENT">Agent</option>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
<option value="SERVICE">Service</option>
</select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div>
@@ -375,7 +371,7 @@ export default function AdminUsers() {
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
</button>
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AUDIT_LABELS, AUDIT_COLORS } from '../../../../shared/constants/labels';
import { injectMentionLinks } from '../../lib/mentions';
import type { AuditLog, User } from '../../types';
const COMMENT_ACTIONS = new Set(['COMMENT_ADDED', 'COMMENT_DELETED']);
interface TicketAuditLogProps {
auditLogs: AuditLog[];
users: User[];
}
export default function TicketAuditLog({ auditLogs, users }: TicketAuditLogProps) {
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const toggleLog = (logId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev);
if (next.has(logId)) next.delete(logId);
else next.add(logId);
return next;
});
};
if (auditLogs.length === 0) {
return (
<div className="p-6">
<div className="py-10 text-center text-sm text-gray-600">No activity yet</div>
</div>
);
}
return (
<div className="p-6">
<div>
{auditLogs.map((log, i) => {
const hasDetail = !!log.detail;
const isExpanded = expandedLogs.has(log.id);
const isComment = COMMENT_ACTIONS.has(log.action);
return (
<div key={log.id} className="flex gap-4">
<div className="flex flex-col items-center w-5 flex-shrink-0">
<div
className={`w-2.5 h-2.5 rounded-full mt-1 flex-shrink-0 ${AUDIT_COLORS[log.action] ?? 'bg-gray-500'}`}
/>
{i < auditLogs.length - 1 && (
<div className="w-px flex-1 bg-gray-800 my-1" />
)}
</div>
<div className="flex-1 pb-4">
<div
className={`flex items-baseline justify-between gap-4 ${hasDetail ? 'cursor-pointer select-none' : ''}`}
onClick={() => hasDetail && toggleLog(log.id)}
>
<p className="text-sm text-gray-300">
<span className="font-medium text-gray-100">
{log.user.displayName}
</span>{' '}
{AUDIT_LABELS[log.action] ?? log.action.toLowerCase()}
{hasDetail && (
<span className="ml-1 inline-flex items-center text-gray-600">
{isExpanded ? (
<ChevronDown size={13} />
) : (
<ChevronRight size={13} />
)}
</span>
)}
</p>
<span
className="text-xs text-gray-600 flex-shrink-0"
title={format(new Date(log.createdAt), 'MMM d, yyyy HH:mm:ss')}
>
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
{hasDetail && isExpanded && (
<div className="mt-2 ml-0 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3">
{isComment ? (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(log.detail!, users)}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,161 @@
import { useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Trash2, Send } from 'lucide-react';
import Avatar from '../../components/Avatar';
import MentionTextarea from '../../components/MentionTextarea';
import { injectMentionLinks } from '../../lib/mentions';
import type { Ticket, User, Comment } from '../../types';
interface TicketCommentsProps {
ticket: Ticket & { comments?: Comment[] };
users: User[];
authUser: { id: string; displayName: string; role: string } | null;
isAdmin: boolean;
commentBody: string;
setCommentBody: (v: string) => void;
preview: boolean;
setPreview: (v: boolean) => void;
commentTextareaRef: React.Ref<HTMLTextAreaElement>;
onSubmitComment: (e: React.FormEvent) => void;
onDeleteComment: (commentId: string) => void;
isSubmitting: boolean;
}
export default function TicketComments({
ticket,
users,
authUser,
isAdmin,
commentBody,
setCommentBody,
preview,
setPreview,
commentTextareaRef,
onSubmitComment,
onDeleteComment,
isSubmitting,
}: TicketCommentsProps) {
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const toggleDate = (commentId: string) =>
setExpandedDates((prev) => {
const next = new Set(prev);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
return (
<div className="p-6 space-y-4">
{ticket.comments && ticket.comments.length > 0 ? (
ticket.comments.map((comment, i) => (
<div key={comment.id} className="flex gap-3 group">
<div className="flex flex-col items-center">
<Avatar name={comment.author.displayName} size="md" />
{i < ticket.comments!.length - 1 && (
<div className="flex-1 w-px bg-gray-800 mt-2" />
)}
</div>
<div className="flex-1 min-w-0 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 border-b border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-200">
{comment.author.displayName}
</span>
<button
onClick={() => toggleDate(comment.id)}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
{expandedDates.has(comment.id)
? format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</button>
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => onDeleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
</button>
)}
</div>
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(comment.body, users)}
</ReactMarkdown>
</div>
</div>
</div>
))
) : (
<div className="py-12 text-center text-sm text-gray-600">No comments yet</div>
)}
{/* Composer */}
<div className="flex gap-3">
<Avatar name={authUser?.displayName ?? '?'} size="md" />
<div className="flex-1 border border-gray-700 rounded-lg overflow-hidden">
<div className="flex gap-4 px-4 bg-gray-800/60 border-b border-gray-700">
{(['Write', 'Preview'] as const).map((label) => (
<button
key={label}
onClick={() => setPreview(label === 'Preview')}
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
(label === 'Preview') === preview
? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
{label}
</button>
))}
</div>
<form onSubmit={onSubmitComment} className="p-3">
{preview ? (
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
{commentBody.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{injectMentionLinks(commentBody, users)}
</ReactMarkdown>
) : (
<span className="text-gray-600 italic">Nothing to preview</span>
)}
</div>
) : (
<div className="mb-3">
<MentionTextarea
ref={commentTextareaRef}
value={commentBody}
onChange={setCommentBody}
users={users}
onSubmit={() =>
onSubmitComment({
preventDefault: () => {},
} as unknown as React.FormEvent)
}
placeholder="Leave a comment… Markdown & @mentions"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none"
/>
</div>
)}
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button
type="submit"
disabled={isSubmitting || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />
Comment
</button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,354 @@
import { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useShortcut } from '../../hooks/useShortcuts';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Pencil, X, Check, MessageSquare, ClipboardList, FileText, ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import Modal from '../../components/Modal';
import SeverityBadge from '../../components/SeverityBadge';
import StatusBadge from '../../components/StatusBadge';
import CTISelect from '../../components/CTISelect';
import { useAuth } from '../../contexts/AuthContext';
import {
useTicket,
useTicketAudit,
useUpdateTicket,
useDeleteTicket,
useUsers,
useAddComment,
useDeleteComment,
} from '../../api/queries';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { UpdateTicketInput } from '../../../../shared/schemas/ticket';
import TicketComments from './TicketComments';
import TicketAuditLog from './TicketAuditLog';
import TicketSidebar from './TicketSidebar';
type Tab = 'overview' | 'comments' | 'audit';
export default function TicketDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const [tab, setTab] = useState<Tab>('overview');
const [editing, setEditing] = useState(false);
const [reroutingCTI, setReroutingCTI] = useState(false);
const [commentBody, setCommentBody] = useState('');
const [preview, setPreview] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [editForm, setEditForm] = useState({ title: '', overview: '' });
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' });
// Draft autosave
const draftKey = id ? `comment-draft:${id}` : null;
useEffect(() => {
if (!draftKey) return;
const saved = localStorage.getItem(draftKey);
if (saved) setCommentBody(saved);
}, [draftKey]);
useEffect(() => {
if (!draftKey) return;
if (commentBody) localStorage.setItem(draftKey, commentBody);
else localStorage.removeItem(draftKey);
}, [commentBody, draftKey]);
const { data: ticket, isLoading } = useTicket(id);
const { data: users = [] } = useUsers();
const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit');
const updateTicket = useUpdateTicket();
const deleteTicketMutation = useDeleteTicket();
const addComment = useAddComment(id);
const deleteCommentMutation = useDeleteComment(id);
const isAdmin = authUser?.role === 'ADMIN';
const patch = async (payload: UpdateTicketInput) => {
if (!ticket) return;
await updateTicket.mutateAsync({ id: ticket.displayId, data: payload });
};
const startEdit = () => {
if (!ticket) return;
setEditForm({ title: ticket.title, overview: ticket.overview });
setEditing(true);
setTab('overview');
};
const saveEdit = async () => {
await patch({ title: editForm.title, overview: editForm.overview });
setEditing(false);
};
const startReroute = () => {
if (!ticket) return;
setPendingCTI({ categoryId: ticket.categoryId, typeId: ticket.typeId, itemId: ticket.itemId });
setReroutingCTI(true);
};
const saveReroute = async () => {
await patch(pendingCTI);
setReroutingCTI(false);
};
const confirmDeleteTicket = async () => {
if (!ticket) return;
await deleteTicketMutation.mutateAsync(ticket.displayId);
toast.success('Ticket deleted');
navigate('/tickets');
};
const submitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!ticket || !commentBody.trim()) return;
await addComment.mutateAsync(commentBody.trim());
setCommentBody('');
setPreview(false);
if (draftKey) localStorage.removeItem(draftKey);
};
const handleDeleteComment = async (commentId: string) => {
await deleteCommentMutation.mutateAsync(commentId);
};
useShortcut('e', (e) => {
if (!ticket || editing) return;
e.preventDefault();
startEdit();
}, [ticket, editing]);
useShortcut('r', (e) => {
if (!ticket) return;
e.preventDefault();
setTab('comments');
setPreview(false);
setTimeout(() => commentTextareaRef.current?.focus(), 40);
}, [ticket]);
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Loading...
</div>
</Layout>
);
}
if (!ticket) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
Ticket not found
</div>
</Layout>
);
}
const commentCount = ticket.comments?.length ?? 0;
return (
<Layout>
<button
onClick={() => navigate('/tickets')}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
>
<ArrowLeft size={14} />
All tickets
</button>
<div className="flex flex-col-reverse md:flex-row gap-6 md:items-start">
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Title card */}
<div className="bg-gray-900 border border-gray-800 rounded-xl px-6 py-5 mb-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="font-mono text-xs font-semibold text-gray-500 bg-gray-800 px-2 py-0.5 rounded">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-500 ml-1">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
<button
onClick={startEdit}
className="ml-auto text-gray-500 hover:text-gray-300 transition-colors"
title="Edit title & overview"
>
<Pencil size={14} />
</button>
</div>
{editing ? (
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full text-2xl font-bold text-gray-100 bg-transparent border-0 border-b-2 border-indigo-500 focus:outline-none pb-1"
autoFocus
/>
) : (
<h1 className="text-2xl font-bold text-gray-100">{ticket.title}</h1>
)}
</div>
{/* Tabs + content */}
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<div className="flex border-b border-gray-800 px-2">
{(
[
{ key: 'overview', icon: FileText, label: 'Overview' },
{
key: 'comments',
icon: MessageSquare,
label: `Comments${commentCount > 0 ? ` (${commentCount})` : ''}`,
},
{ key: 'audit', icon: ClipboardList, label: 'Audit Log' },
] as const
).map(({ key, icon: Icon, label }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-2 px-4 py-3.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === key
? 'border-indigo-500 text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}
>
<Icon size={14} />
{label}
</button>
))}
</div>
{tab === 'overview' && (
<div className="p-6">
{editing ? (
<div className="space-y-3">
<textarea
value={editForm.overview}
onChange={(e) => setEditForm((f) => ({ ...f, overview: e.target.value }))}
rows={12}
className="w-full bg-gray-800 border border-gray-700 text-gray-100 rounded-lg px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-y font-mono"
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setEditing(false)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
<X size={13} /> Cancel
</button>
<button
onClick={saveEdit}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<Check size={13} /> Save changes
</button>
</div>
</div>
) : (
<div className="prose text-sm text-gray-300">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{ticket.overview}</ReactMarkdown>
</div>
)}
</div>
)}
{tab === 'comments' && (
<TicketComments
ticket={ticket}
users={users}
authUser={authUser}
isAdmin={isAdmin}
commentBody={commentBody}
setCommentBody={setCommentBody}
preview={preview}
setPreview={setPreview}
commentTextareaRef={commentTextareaRef}
onSubmitComment={submitComment}
onDeleteComment={handleDeleteComment}
isSubmitting={addComment.isPending}
/>
)}
{tab === 'audit' && <TicketAuditLog auditLogs={auditLogs} users={users} />}
</div>
</div>
<TicketSidebar
ticket={ticket}
users={users}
isAdmin={isAdmin}
patch={patch}
onReroute={startReroute}
onDelete={() => setDeleteOpen(true)}
/>
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this ticket?</AlertDialogTitle>
<AlertDialogDescription>
{ticket.displayId} · {ticket.title} will be permanently removed along with its
comments and audit log. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTicket}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{reroutingCTI && (
<Modal title="Change Routing" onClose={() => setReroutingCTI(false)} size="lg">
<div className="space-y-5">
<p className="text-sm text-gray-400">
Current:{' '}
<span className="text-gray-200">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
</p>
<CTISelect value={pendingCTI} onChange={setPendingCTI} />
<div className="flex justify-end gap-3 pt-1">
<button
onClick={() => setReroutingCTI(false)}
className="px-4 py-2 text-sm text-gray-400 border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
</button>
<button
onClick={saveReroute}
disabled={!pendingCTI.itemId}
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
Save routing
</button>
</div>
</div>
</Modal>
)}
</Layout>
);
}
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { format, formatDistanceToNow } from 'date-fns';
import { Trash2, Check } from 'lucide-react';
import SeverityBadge from '../../components/SeverityBadge';
import StatusBadge from '../../components/StatusBadge';
import Avatar from '../../components/Avatar';
import type { Ticket, User, TicketStatus } from '../../types';
import type { UpdateTicketInput } from '../../../../shared/schemas/ticket';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
const SEVERITY_OPTIONS = [
{ value: 1, label: 'SEV 1 — Critical' },
{ value: 2, label: 'SEV 2 — High' },
{ value: 3, label: 'SEV 3 — Medium' },
{ value: 4, label: 'SEV 4 — Low' },
{ value: 5, label: 'SEV 5 — Minimal' },
];
interface TicketSidebarProps {
ticket: Ticket;
users: User[];
isAdmin: boolean;
patch: (payload: UpdateTicketInput) => Promise<void>;
onReroute: () => void;
onDelete: () => void;
}
export default function TicketSidebar({
ticket,
users,
isAdmin,
patch,
onReroute,
onDelete,
}: TicketSidebarProps) {
const [statusOpen, setStatusOpen] = useState(false);
const [severityOpen, setSeverityOpen] = useState(false);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
{ value: 'RESOLVED', label: 'Resolved' },
...(isAdmin ? [{ value: 'CLOSED' as TicketStatus, label: 'Closed' }] : []),
];
return (
<div className="w-full md:w-64 flex-shrink-0 md:sticky md:top-0 space-y-3">
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
<div className="px-4 py-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Ticket Summary
</p>
</div>
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Status</p>
<StatusBadge status={ticket.status} />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value });
setStatusOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<StatusBadge status={s.value} />
{ticket.status === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
{!isAdmin && (
<p className="px-2 pt-1 text-[11px] text-muted-foreground">
Closing requires admin
</p>
)}
</PopoverContent>
</Popover>
{/* Severity */}
<Popover open={severityOpen} onOpenChange={setSeverityOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Severity</p>
<SeverityBadge severity={ticket.severity} />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value });
setSeverityOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<SeverityBadge severity={s.value} />
<span className="text-muted-foreground text-xs">
{s.label.split(' — ')[1]}
</span>
{ticket.severity === s.value && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</PopoverContent>
</Popover>
{/* CTI */}
<button
onClick={onReroute}
className="w-full px-4 py-3 text-left space-y-3 hover:bg-gray-800/50 transition-colors"
>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Category</p>
<p className="text-sm text-gray-300">{ticket.category.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Type</p>
<p className="text-sm text-gray-300">{ticket.type.name}</p>
</div>
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Issue</p>
<p className="text-sm text-gray-300">{ticket.item.name}</p>
</div>
</button>
{/* Dates */}
<div className="px-4 py-3 space-y-2.5">
{[
{ key: 'created', label: 'Created', date: ticket.createdAt },
{ key: 'modified', label: 'Modified', date: ticket.updatedAt },
...(ticket.resolvedAt
? [{ key: 'resolved', label: 'Resolved', date: ticket.resolvedAt }]
: []),
].map(({ key, label, date }) => (
<div key={key}>
<p className="text-xs font-medium text-gray-500 mb-1">{label}</p>
<button
onClick={() => toggleDate(key)}
className="text-xs text-gray-300 hover:text-gray-100 transition-colors"
>
{expandedDates.has(key)
? format(new Date(date), 'MMM d, yyyy HH:mm')
: formatDistanceToNow(new Date(date), { addSuffix: true })}
</button>
</div>
))}
</div>
{/* Assignee */}
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<button className="w-full px-4 py-3 text-left hover:bg-gray-800/50 transition-colors">
<p className="text-xs font-medium text-gray-500 mb-1.5">Assignee</p>
{ticket.assignee ? (
<div className="flex items-center gap-1.5">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.assignee.displayName}</span>
</div>
) : (
<p className="text-sm text-gray-500">Unassigned</p>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-1">
<button
onClick={async () => {
await patch({ assigneeId: null });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<span className="text-muted-foreground">Unassigned</span>
{!ticket.assigneeId && <Check size={13} className="ml-auto text-primary" />}
</button>
<div className="max-h-64 overflow-auto">
{users.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id });
setAssigneeOpen(false);
}}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-sm hover:bg-accent"
>
<Avatar name={u.displayName} size="sm" />
<span>{u.displayName}</span>
{ticket.assigneeId === u.id && (
<Check size={13} className="ml-auto text-primary" />
)}
</button>
))}
</div>
</PopoverContent>
</Popover>
{/* Requester */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-gray-500 mb-1.5">Requester</p>
<div className="flex items-center gap-1.5">
<Avatar name={ticket.createdBy.displayName} size="sm" />
<span className="text-sm text-gray-300">{ticket.createdBy.displayName}</span>
</div>
</div>
</div>
{isAdmin && (
<div className="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3">
<button
onClick={onDelete}
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/10 transition-colors"
>
<Trash2 size={13} />
Delete ticket
</button>
</div>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from './TicketDetail';
+78
View File
@@ -0,0 +1,78 @@
import type { User } from '../../types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface BulkActionsProps {
selectedCount: number;
users: User[];
onClear: () => void;
onConfirm: (bulk: { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }) => void;
}
export default function BulkActions({ selectedCount, users, onClear, onConfirm }: BulkActionsProps) {
return (
<div className="flex items-center gap-3 mb-3 px-3 py-2 rounded-md bg-accent border border-border">
<span className="text-sm font-medium">
{selectedCount} selected
</span>
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</button>
<div className="h-4 w-px bg-border mx-1" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Reassign
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => onConfirm({ kind: 'reassign', value: null, label: 'Unassign' })}
>
Unassigned
</DropdownMenuItem>
{users.map((u) => (
<DropdownMenuItem
key={u.id}
onSelect={() => onConfirm({ kind: 'reassign', value: u.id, label: `Assign to ${u.displayName}` })}
>
{u.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="px-2 py-1 rounded-md text-sm hover:bg-background">
Severity
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{[1, 2, 3, 4, 5].map((s) => (
<DropdownMenuItem
key={s}
onSelect={() => onConfirm({ kind: 'setSeverity', value: s, label: `Set severity SEV ${s}` })}
>
SEV {s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<button
onClick={() => onConfirm({ kind: 'close', label: 'Close selected tickets' })}
className="px-2 py-1 rounded-md text-sm hover:bg-background"
>
Close
</button>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { Trash2, Save, Check } from 'lucide-react';
import CTISelect from '../../components/CTISelect';
import { TICKET_STATUSES } from '../../../../shared/schemas/enums';
import type { User } from '../../types';
import type { SavedView } from '../../../../shared/types';
import { STATUS_LABELS } from '../../../../shared/constants/labels';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
interface TicketFiltersProps {
status: string;
severity: string;
assigneeId: string;
categoryId: string;
typeId: string;
itemId: string;
searchInput: string;
onSearchChange: (v: string) => void;
onUpdateParam: (key: string, value: string | null) => void;
onSetParams: (fn: (prev: URLSearchParams) => URLSearchParams) => void;
authUser: { id: string; displayName: string } | null;
users: User[];
savedViews: SavedView[];
onDeleteView: (id: string) => void;
onApplyView: (filters: Record<string, unknown>) => void;
onSaveView: () => void;
activeFilterCount: number;
total: number;
isFetching: boolean;
}
export default function TicketFilters({
status,
severity,
assigneeId,
categoryId,
typeId,
itemId,
searchInput,
onSearchChange,
onUpdateParam,
onSetParams,
authUser,
users,
savedViews,
onDeleteView,
onApplyView,
onSaveView,
activeFilterCount,
total,
isFetching,
}: TicketFiltersProps) {
const selectedStatuses = status ? status.split(',') : [];
const toggleStatus = (s: string) => {
const next = selectedStatuses.includes(s)
? selectedStatuses.filter((v) => v !== s)
: [...selectedStatuses, s];
onUpdateParam('status', next.length > 0 ? next.join(',') : null);
};
const statusLabel =
selectedStatuses.length === 0 || selectedStatuses.length === TICKET_STATUSES.length
? 'All statuses'
: selectedStatuses.map((s) => STATUS_LABELS[s] ?? s).join(', ');
return (
<>
{/* Row 1: Search + saved views + result count */}
<div className="flex gap-2 mb-2 items-center">
<input
type="search"
value={searchInput}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search title, overview, ID…"
className="flex-1 min-w-48 max-w-sm px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Save size={14} />
Saved views
{savedViews.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px] leading-4">
{savedViews.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>Saved views</DropdownMenuLabel>
<DropdownMenuSeparator />
{savedViews.length > 0 ? (
savedViews.map((v) => (
<div
key={v.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-accent rounded-sm"
>
<button
onClick={() => onApplyView(v.filters)}
className="flex-1 text-left text-sm truncate"
>
{v.name}
</button>
<button
onClick={() => onDeleteView(v.id)}
aria-label="Delete view"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p className="px-2 py-1 text-xs text-muted-foreground">No saved views yet</p>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={activeFilterCount === 0}
onSelect={onSaveView}
className="gap-2"
>
<Save size={13} />
Save current filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="ml-auto text-xs text-muted-foreground">
{isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`}
</div>
</div>
{/* Row 2: Filter selectors */}
<div className="flex gap-2 mb-4 items-center flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5 text-sm font-normal">
{statusLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{TICKET_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onSelect={(e) => {
e.preventDefault();
toggleStatus(s);
}}
className="gap-2"
>
<div className="flex h-4 w-4 items-center justify-center rounded-sm border border-input">
{selectedStatuses.includes(s) && <Check size={12} />}
</div>
{STATUS_LABELS[s] ?? s}
</DropdownMenuItem>
))}
{selectedStatuses.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.set('status', '');
next.delete('page');
return next;
});
}}
className="justify-center text-xs text-muted-foreground"
>
Clear selection
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<select
value={severity}
onChange={(e) => onUpdateParam('severity', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
<select
value={assigneeId}
onChange={(e) => onUpdateParam('assigneeId', e.target.value || null)}
className="px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">All assignees</option>
{authUser && (
<option value={authUser.id}>Me ({authUser.displayName})</option>
)}
{users
.filter((u) => u.id !== authUser?.id)
.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName}
</option>
))}
</select>
<CTISelect
compact
value={{ categoryId, typeId, itemId }}
onChange={(cti) => {
onSetParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('page');
if (cti.categoryId) next.set('categoryId', cti.categoryId);
else next.delete('categoryId');
if (cti.typeId) next.set('typeId', cti.typeId);
else next.delete('typeId');
if (cti.itemId) next.set('itemId', cti.itemId);
else next.delete('itemId');
return next;
});
}}
/>
</div>
</>
);
}
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { SEVERITY_BG } from '../../lib/severityColors';
import SeverityBadge from '../../components/SeverityBadge';
import StatusBadge from '../../components/StatusBadge';
import Avatar from '../../components/Avatar';
import type { Ticket } from '../../types';
interface TicketListItemProps {
ticket: Ticket;
selected: boolean;
focused: boolean;
onToggle: () => void;
}
export default function TicketListItem({ ticket, selected, focused, onToggle }: TicketListItemProps) {
return (
<li
className={`flex items-center gap-3 px-4 py-3 transition-colors ${
focused
? 'bg-accent/50 ring-1 ring-inset ring-primary'
: 'hover:bg-accent/30'
}`}
>
<input
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`Select ${ticket.displayId}`}
className="cursor-pointer flex-shrink-0"
/>
<div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${SEVERITY_BG[ticket.severity] ?? 'bg-gray-600'}`}
/>
<Link
to={`/${ticket.displayId}`}
className="flex-1 min-w-0 flex items-center gap-3 group"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-sm font-medium text-foreground group-hover:text-primary truncate">
{ticket.title}
</span>
<span className="text-xs font-mono text-muted-foreground">
{ticket.displayId}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-wrap">
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span>
opened {formatDistanceToNow(new Date(ticket.createdAt), {
addSuffix: true,
})}{' '}
by {ticket.createdBy.displayName}
</span>
<span className="hidden md:inline">
· {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span>
{ticket.assignee && (
<span className="hidden md:inline">· assigned {ticket.assignee.displayName}</span>
)}
<span>· {ticket._count?.comments ?? 0} comments</span>
</div>
</div>
<div className="hidden sm:flex items-center flex-shrink-0">
{ticket.assignee ? (
<Avatar name={ticket.assignee.displayName} size="sm" />
) : null}
</div>
</Link>
</li>
);
}
+378
View File
@@ -0,0 +1,378 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useShortcut } from '../../hooks/useShortcuts';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { useAuth } from '../../contexts/AuthContext';
import {
useTicketsPaged,
useBulkTickets,
useSavedViews,
useCreateSavedView,
useDeleteSavedView,
useUsers,
} from '../../api/queries';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import TicketFilters from './TicketFilters';
import BulkActions from './BulkActions';
import TicketListItem from './TicketListItem';
const PAGE_SIZE = 25;
const BULK_LIMIT = 500;
export default function Tickets() {
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user: authUser } = useAuth();
const { data: users = [] } = useUsers();
const DEFAULT_STATUSES = 'OPEN,IN_PROGRESS';
const statusParam = params.get('status');
const status = statusParam === null ? DEFAULT_STATUSES : statusParam;
const severity = params.get('severity') ?? '';
const assigneeId = params.get('assigneeId') ?? '';
const categoryId = params.get('categoryId') ?? '';
const typeId = params.get('typeId') ?? '';
const itemId = params.get('itemId') ?? '';
const search = params.get('search') ?? '';
const page = Math.max(1, Number(params.get('page') ?? '1'));
const [searchInput, setSearchInput] = useState(search);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [cursor, setCursor] = useState<number>(-1);
const [saveOpen, setSaveOpen] = useState(false);
const [newViewName, setNewViewName] = useState('');
const [confirmBulk, setConfirmBulk] = useState<
| { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string }
| null
>(null);
useEffect(() => {
const t = setTimeout(() => {
if (search !== searchInput) {
updateParam('search', searchInput);
updateParam('page', '1');
}
}, 250);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const updateParam = (key: string, value: string | null) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
if (value === null || value === '') next.delete(key);
else next.set(key, value);
if (key !== 'page') next.delete('page');
return next;
},
{ replace: true },
);
};
const queryParams = {
status: status || undefined as string | undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
page,
pageSize: PAGE_SIZE,
};
const { data, isLoading, isFetching } = useTicketsPaged(queryParams);
const tickets = data?.data ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const savedViewsQ = useSavedViews();
const createView = useCreateSavedView();
const deleteView = useDeleteSavedView();
const bulk = useBulkTickets();
const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id));
const someVisible = tickets.some((t) => selected.has(t.id));
const toggleAll = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allVisible) tickets.forEach((t) => next.delete(t.id));
else tickets.forEach((t) => next.add(t.id));
return next;
});
};
const toggleOne = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const clearSelected = () => setSelected(new Set());
const currentFilters = useMemo(
() => ({
status: status || undefined,
severity: severity ? Number(severity) : undefined,
assigneeId: assigneeId || undefined,
categoryId: categoryId || undefined,
typeId: typeId || undefined,
itemId: itemId || undefined,
search: search || undefined,
}),
[status, severity, assigneeId, categoryId, typeId, itemId, search],
);
const runBulk = async (payload: { action: string; value?: unknown }) => {
const ids = Array.from(selected).slice(0, BULK_LIMIT);
try {
const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number };
toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`);
clearSelected();
} catch (e) {
toast.error((e as Error).message || 'Bulk action failed');
} finally {
setConfirmBulk(null);
}
};
const activeCount = Object.values(currentFilters).filter(Boolean).length;
const handleSaveView = async () => {
if (!newViewName.trim()) return;
try {
await createView.mutateAsync({
name: newViewName.trim(),
filters: currentFilters,
});
toast.success('Saved view');
setNewViewName('');
setSaveOpen(false);
} catch (e) {
toast.error((e as Error).message || 'Failed to save view');
}
};
const applyView = (filters: Record<string, unknown>) => {
const next = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') next.set(k, String(v));
});
setParams(next, { replace: true });
setSearchInput(String(filters.search ?? ''));
};
// Keyboard navigation
useEffect(() => {
setCursor((c) => (c >= tickets.length ? tickets.length - 1 : c));
}, [tickets.length]);
useShortcut('j', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.min(tickets.length - 1, c < 0 ? 0 : c + 1));
}, [tickets.length]);
useShortcut('k', (e) => {
if (tickets.length === 0) return;
e.preventDefault();
setCursor((c) => Math.max(0, c < 0 ? 0 : c - 1));
}, [tickets.length]);
useShortcut('enter', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
navigate(`/${tickets[cursor].displayId}`);
}, [cursor, tickets]);
useShortcut('x', (e) => {
if (cursor < 0 || cursor >= tickets.length) return;
e.preventDefault();
toggleOne(tickets[cursor].id);
}, [cursor, tickets]);
return (
<Layout wide>
<TicketFilters
status={status}
severity={severity}
assigneeId={assigneeId}
categoryId={categoryId}
typeId={typeId}
itemId={itemId}
searchInput={searchInput}
onSearchChange={setSearchInput}
onUpdateParam={updateParam}
onSetParams={(fn) => setParams(fn, { replace: true })}
authUser={authUser}
users={users}
savedViews={savedViewsQ.data ?? []}
onDeleteView={(id) => deleteView.mutate(id)}
onApplyView={applyView}
onSaveView={() => setSaveOpen(true)}
activeFilterCount={activeCount}
total={total}
isFetching={isFetching}
/>
{selected.size > 0 && (
<BulkActions
selectedCount={selected.size}
users={users}
onClear={clearSelected}
onConfirm={setConfirmBulk}
/>
)}
{/* Ticket list */}
{isLoading ? (
<div className="text-center py-16 text-muted-foreground text-sm">Loading</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">No tickets found</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-card text-xs text-muted-foreground border-b border-border">
<input
type="checkbox"
checked={allVisible}
aria-label="Select all on page"
ref={(el) => {
if (el) el.indeterminate = !allVisible && someVisible;
}}
onChange={toggleAll}
className="cursor-pointer"
/>
<span>
{tickets.length} of {total}
</span>
</div>
<ul className="divide-y divide-border">
{tickets.map((ticket, idx) => (
<TicketListItem
key={ticket.id}
ticket={ticket}
selected={selected.has(ticket.id)}
focused={idx === cursor}
onToggle={() => toggleOne(ticket.id)}
/>
))}
</ul>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<div className="text-muted-foreground">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-1">
<button
disabled={page <= 1}
onClick={() => updateParam('page', String(page - 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
disabled={page >= totalPages}
onClick={() => updateParam('page', String(page + 1))}
className="p-1.5 rounded-md hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
{/* Save view dialog */}
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save current filters</DialogTitle>
</DialogHeader>
<div className="py-2">
<input
autoFocus
type="text"
value={newViewName}
onChange={(e) => setNewViewName(e.target.value)}
placeholder="View name"
className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveView();
}}
/>
</div>
<DialogFooter>
<button
onClick={() => setSaveOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleSaveView}
disabled={!newViewName.trim() || createView.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Save
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirm bulk action */}
<AlertDialog open={!!confirmBulk} onOpenChange={(o) => !o && setConfirmBulk(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirmBulk?.label}</AlertDialogTitle>
<AlertDialogDescription>
This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (!confirmBulk) return;
if (confirmBulk.kind === 'close') runBulk({ action: 'close' });
else
runBulk({
action: confirmBulk.kind,
value: confirmBulk.value,
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from './Tickets';
+1 -1
View File
@@ -1,4 +1,4 @@
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE';
export type Role = 'ADMIN' | 'AGENT' | 'USER';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export interface User {
+9
View File
@@ -1,4 +1,13 @@
# Development — used with 'npm run dev' (see root .env.example for Docker/production)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ticketing"
JWT_SECRET="change-this-to-a-long-random-secret"
CLIENT_URL="http://localhost:5173"
PORT=3000
# Email notifications (optional) — leave SMTP_HOST empty to disable
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@localhost
SMTP_SECURE=false
+2 -1
View File
@@ -1,7 +1,8 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
COPY server/package*.json ./server/
RUN cd server && npm ci
RUN npm ci --omit=dev && cd server && npm ci
COPY server ./server
COPY shared ./shared
RUN cd server && npx prisma generate && npm run build
+12 -29
View File
@@ -14,7 +14,6 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.0",
"express-rate-limit": "^7.5.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
@@ -39,7 +38,7 @@
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8",
"vitest-mock-extended": "^4.0.0"
"vitest-mock-extended": "^2.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -542,14 +541,14 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -563,14 +562,14 @@
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0",
@@ -582,7 +581,7 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.22.0"
@@ -1917,21 +1916,6 @@
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-copy": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
@@ -2019,7 +2003,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -2689,7 +2672,7 @@
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3996,17 +3979,17 @@
}
},
"node_modules/vitest-mock-extended": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-4.0.0.tgz",
"integrity": "sha512-m2FmH8JYfxzZoLsHuhXRY+Pv++a3zd91HYpSz81tpRLEHbtFkEL2QcWvJowucWuNTirzQURKfWbJJSXbYqkTsA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-2.0.2.tgz",
"integrity": "sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ts-essentials": ">=10.0.0"
},
"peerDependencies": {
"typescript": "3.x || 4.x || 5.x || 6.x",
"vitest": ">=4.0.0"
"typescript": "3.x || 4.x || 5.x",
"vitest": ">=2.0.0"
}
},
"node_modules/vitest/node_modules/debug": {
+3 -4
View File
@@ -5,8 +5,8 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/server/src/index.js",
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push --accept-data-loss && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push --accept-data-loss && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
@@ -20,7 +20,6 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-errors": "^3.1.0",
"express-rate-limit": "^7.5.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3",
@@ -45,6 +44,6 @@
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8",
"vitest-mock-extended": "^4.0.0"
"vitest-mock-extended": "^2.0.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
-- Idempotent SQL applied BEFORE `prisma db push`.
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
) THEN
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
END IF;
END $$;
+11 -1
View File
@@ -11,7 +11,6 @@ enum Role {
ADMIN
AGENT
USER
SERVICE
}
enum TicketStatus {
@@ -58,6 +57,7 @@ model Type {
tickets Ticket[]
@@unique([categoryId, name])
@@index([categoryId])
}
model Item {
@@ -68,6 +68,7 @@ model Item {
tickets Ticket[]
@@unique([typeId, name])
@@index([typeId])
}
model Ticket {
@@ -87,6 +88,9 @@ model Ticket {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Managed by post-push.sql — trigger keeps it in sync; queried only via raw SQL.
searchVector Unsupported("tsvector")?
category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@ -111,6 +115,9 @@ model Comment {
authorId String
createdAt DateTime @default(now())
// Managed by post-push.sql — trigger keeps it in sync; queried only via raw SQL.
searchVector Unsupported("tsvector")?
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
attachments Attachment[]
@@ -136,6 +143,7 @@ model Attachment {
@@index([ticketId])
@@index([commentId])
@@index([uploadedById])
}
model Webhook {
@@ -190,4 +198,6 @@ model AuditLog {
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
@@index([ticketId, createdAt])
}
-20
View File
@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
const prisma = new PrismaClient();
@@ -20,25 +19,6 @@ async function main() {
},
});
// Goddard — n8n service account
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
await prisma.user.upsert({
where: { username: 'goddard' },
update: {},
create: {
username: 'goddard',
email: 'goddard@internal',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
role: 'SERVICE',
apiKey,
},
});
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
console.log('(This key is only displayed once on first seed — copy it now)\n');
// Sample CTI structure
const theWrightServer = await prisma.category.upsert({
where: { name: 'TheWrightServer' },
-10
View File
@@ -3,7 +3,6 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pinoHttp from 'pino-http';
import rateLimit from 'express-rate-limit';
import authRoutes from './routes/auth';
import ticketRoutes from './routes/tickets';
@@ -38,16 +37,7 @@ app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});
// Public
app.use('/api/auth/login', loginLimiter);
app.use('/api/auth', authRoutes);
// Protected
+2 -2
View File
@@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user || user.role !== 'SERVICE') {
if (!user) {
return res.status(401).json({ error: 'Invalid API key' });
}
req.user = { id: user.id, role: user.role, username: user.username };
@@ -48,7 +48,7 @@ export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction
next();
};
// Blocks USER role — allows ADMIN, AGENT, SERVICE
// Blocks USER role — allows ADMIN and AGENT
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' });
+2 -1
View File
@@ -1,9 +1,10 @@
import { Router } from 'express';
import * as analyticsService from '../services/analyticsService';
import { requireAgent } from '../middleware/auth';
const router = Router();
router.get('/summary', async (req, res) => {
router.get('/summary', requireAgent, async (req, res) => {
const raw = Number(req.query.window);
const window: analyticsService.AnalyticsWindow =
raw === 14 || raw === 30 || raw === 90 ? raw : 30;
+2 -1
View File
@@ -1,5 +1,6 @@
import { Router } from 'express';
import * as ticketService from '../services/ticketService';
import { requireAgent } from '../middleware/auth';
const router = Router();
@@ -10,7 +11,7 @@ function csvEscape(v: unknown): string {
return s;
}
router.get('/tickets.csv', async (req, res) => {
router.get('/tickets.csv', requireAgent, async (req, res) => {
const { status, severity, assigneeId, categoryId, typeId, itemId, search } = req.query;
const tickets = await ticketService.listTickets({
-20
View File
@@ -51,24 +51,4 @@ describe('authService.login', () => {
});
});
it('rejects SERVICE role from password login', async () => {
const password = 'svc-pw';
prismaMock.user.findUnique.mockResolvedValue({
id: 'svc',
username: 'goddard',
email: 'g@x.io',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(password, 4),
role: 'SERVICE',
apiKey: 'sk_xyz',
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
status: 401,
message: expect.stringMatching(/API key/i),
});
});
});
-4
View File
@@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) {
throw new HttpError(401, 'Invalid credentials');
}
if (user.role === 'SERVICE') {
throw new HttpError(401, 'Service accounts must authenticate via API key');
}
const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username },
process.env.JWT_SECRET!,
+2 -3
View File
@@ -2,11 +2,10 @@ import prisma from '../lib/prisma';
import { HttpError } from '../lib/httpError';
import * as notificationService from './notificationService';
import { logger } from '../lib/logger';
import { findByIdOrDisplay } from './ticketService';
export async function addComment(ticketIdOrDisplay: string, body: string, actorId: string) {
const ticket = await prisma.ticket.findFirst({
where: { OR: [{ id: ticketIdOrDisplay }, { displayId: ticketIdOrDisplay }] },
});
const ticket = await findByIdOrDisplay(ticketIdOrDisplay);
if (!ticket) throw new HttpError(404, 'Ticket not found');
const [comment] = await prisma.$transaction([
+8 -1
View File
@@ -6,7 +6,14 @@ function whereConditions(query: string, filters: TicketFilters): Prisma.Sql[] {
const conds: Prisma.Sql[] = [
Prisma.sql`"searchVector" @@ plainto_tsquery('english', ${query})`,
];
if (filters.status) conds.push(Prisma.sql`"status" = ${filters.status}::"TicketStatus"`);
if (filters.status) {
const statuses = filters.status.split(',');
if (statuses.length === 1) {
conds.push(Prisma.sql`"status" = ${statuses[0]}::"TicketStatus"`);
} else {
conds.push(Prisma.sql`"status" IN (${Prisma.join(statuses.map(s => Prisma.sql`${s}::"TicketStatus"`))})`);
}
}
if (filters.severity !== undefined) conds.push(Prisma.sql`"severity" = ${filters.severity}`);
if (filters.assigneeId) conds.push(Prisma.sql`"assigneeId" = ${filters.assigneeId}`);
if (filters.createdById) conds.push(Prisma.sql`"createdById" = ${filters.createdById}`);
+14 -12
View File
@@ -40,12 +40,7 @@ const ticketListInclude = {
_count: { select: { comments: true, attachments: true } },
} as const;
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_PROGRESS: 'In Progress',
RESOLVED: 'Resolved',
CLOSED: 'Closed',
};
import { STATUS_LABELS } from '../../../shared/constants/labels';
export type TicketFilters = {
status?: string;
@@ -75,15 +70,22 @@ async function generateDisplayId(): Promise<string> {
}
}
const idOrDisplayWhere = (idOrDisplay: string): Prisma.TicketWhereInput => ({
OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }],
});
export function findByIdOrDisplay(idOrDisplay: string) {
return prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
});
return prisma.ticket.findFirst({ where: idOrDisplayWhere(idOrDisplay) });
}
export function buildTicketWhere(filters: TicketFilters): Prisma.TicketWhereInput {
const where: Prisma.TicketWhereInput = {};
if (filters.status) where.status = filters.status as Prisma.TicketWhereInput['status'];
if (filters.status) {
const statuses = filters.status.split(',');
where.status = statuses.length === 1
? (statuses[0] as Prisma.TicketWhereInput['status'])
: { in: statuses as string[] } as Prisma.TicketWhereInput['status'];
}
if (filters.severity) where.severity = filters.severity;
if (filters.assigneeId) where.assigneeId = filters.assigneeId;
if (filters.createdById) where.createdById = filters.createdById;
@@ -143,7 +145,7 @@ export async function listTicketsPaged(filters: TicketFilters, pagination: Pagin
export async function getTicket(idOrDisplay: string) {
const ticket = await prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
where: idOrDisplayWhere(idOrDisplay),
include: ticketInclude,
});
if (!ticket) throw new HttpError(404, 'Ticket not found');
@@ -196,7 +198,7 @@ export async function updateTicket(
}
const existing = await prisma.ticket.findFirst({
where: { OR: [{ id: idOrDisplay }, { displayId: idOrDisplay }] },
where: idOrDisplayWhere(idOrDisplay),
include: {
category: true,
type: true,
+10 -9
View File
@@ -17,15 +17,15 @@ const stubUser = {
};
describe('userService.createUser', () => {
it('hashes the password and omits apiKey for non-SERVICE roles', async () => {
prismaMock.user.create.mockResolvedValue(stubUser);
it('hashes the password and omits apiKey for ADMIN and USER roles', async () => {
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' });
await createUser({
username: 'bob',
email: 'b@x.io',
displayName: 'Bob',
password: 'hunter2!',
role: 'AGENT',
role: 'USER',
});
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
@@ -34,14 +34,15 @@ describe('userService.createUser', () => {
expect(call.data.apiKey).toBeUndefined();
});
it('assigns an apiKey for SERVICE role', async () => {
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' });
it('assigns an apiKey for AGENT role', async () => {
prismaMock.user.create.mockResolvedValue(stubUser);
await createUser({
username: 'svc',
email: 's@x.io',
displayName: 'Svc',
role: 'SERVICE',
username: 'agent',
email: 'a@x.io',
displayName: 'Agent',
password: 'hunter2!',
role: 'AGENT',
});
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
+9 -6
View File
@@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) {
}
export async function createUser(data: CreateUserInput) {
const passwordHash = data.password
? await bcrypt.hash(data.password, 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
const passwordHash = await bcrypt.hash(data.password, 12);
const apiKey =
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
data.role === 'AGENT' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
return prisma.user.create({
data: {
@@ -68,8 +66,13 @@ export async function updateUser(id: string, data: UpdateUserInput) {
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
if (data.role) {
update.role = data.role;
if (data.role === 'SERVICE' && !update.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
if (data.role === 'AGENT') {
const existing = await prisma.user.findUnique({ where: { id }, select: { apiKey: true } });
if (!existing?.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
}
} else {
update.apiKey = null;
}
}
if (data.regenerateApiKey) {
+30
View File
@@ -0,0 +1,30 @@
export const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_PROGRESS: 'In Progress',
RESOLVED: 'Resolved',
CLOSED: 'Closed',
};
export const AUDIT_LABELS: Record<string, string> = {
CREATED: 'created this ticket',
STATUS_CHANGED: 'changed status',
ASSIGNEE_CHANGED: 'changed assignee',
SEVERITY_CHANGED: 'changed severity',
REROUTED: 'rerouted ticket',
TITLE_CHANGED: 'updated title',
OVERVIEW_CHANGED: 'updated overview',
COMMENT_ADDED: 'added a comment',
COMMENT_DELETED: 'deleted a comment',
};
export const AUDIT_COLORS: Record<string, string> = {
CREATED: 'bg-green-500',
STATUS_CHANGED: 'bg-blue-500',
ASSIGNEE_CHANGED: 'bg-purple-500',
SEVERITY_CHANGED: 'bg-orange-500',
REROUTED: 'bg-cyan-500',
TITLE_CHANGED: 'bg-gray-500',
OVERVIEW_CHANGED: 'bg-gray-500',
COMMENT_ADDED: 'bg-gray-500',
COMMENT_DELETED: 'bg-red-500',
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod';
export const ROLES = ['ADMIN', 'AGENT', 'USER', 'SERVICE'] as const;
export const ROLES = ['ADMIN', 'AGENT', 'USER'] as const;
export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
export const roleSchema = z.enum(ROLES);
+1 -1
View File
@@ -5,7 +5,7 @@ export const createUserSchema = z.object({
username: z.string().min(1).max(50),
email: z.string().email(),
displayName: z.string().min(1).max(100),
password: z.string().min(8).optional(),
password: z.string().min(8),
role: roleSchema.default('AGENT'),
});