Compare commits

..

32 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
josh 8c2139f6a6 Remove deprecated baseUrl from client tsconfig
Build & Push / Test (client) (push) Failing after 7m24s
Build & Push / Build Client (push) Has been skipped
Build & Push / Test (server) (push) Failing after 31s
Build & Push / Build Server (push) Has been skipped
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:45:09 -04:00
josh 7253068fee Phase 5: ship (healthz, CI test gates, v1.0 README)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:42:47 -04:00
josh ef22e92ac8 Phase 4: power UX (palette, shortcuts, mentions, mobile, PWA)
Command palette (cmd+K) with fuzzy nav, ticket search, people lookup
and action entries (new ticket, logout, show shortcuts). Opens from
keyboard or user dropdown.

Global keyboard shortcuts via a small useShortcut/useLeaderShortcut
hook: `?` help overlay, `c` new ticket, `g d|t|m|n|s` leader nav.
Tickets list: j/k cursor, Enter open, x toggle select. TicketDetail:
`e` edit, `r` focus comment composer. All guarded against firing
inside text fields.

@mention autocomplete in the comment composer (MentionTextarea) with
arrow-key nav and Tab/Enter insert. Rendered comments and audit log
rewrite @username tokens to links pointing at that user's assignee
filter; unknown usernames left as plain text.

Mobile sweep: TicketDetail sidebar stacks below content on <md,
Settings profile grid collapses to one column, admin tables get
horizontal scroll with a 640px min width, CTI 3-column grid stacks
vertically on <md, New ticket severity/assignee grid same.

PWA: manifest.webmanifest, SVG icon, minimal network-first service
worker for the app shell (never caches /api/*), registered in
production builds only. Theme-color meta + manifest link in index.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:36:28 -04:00
josh 4bade22410 Phase 3: UI redesign (Gitea-issues aesthetic)
Top-nav Layout replaces side-nav: brand, primary nav, global search
(debounced /search), notifications bell (Popover + unread badge),
user avatar DropdownMenu. Mobile hamburger collapse.

New pages:
- /dashboard: analytics home (open-by-severity, age buckets, queue
  load, median resolution)
- /tickets: Gitea-style list with status tabs, severity/assignee/CTI
  filters, server pagination (25/page), multi-select bulk bar
  (reassign/close/severity), saved views CRUD
- /notifications: full list with mark-all-read
- /settings: profile, notification prefs grid, API key (SERVICE role)
- /admin/webhooks: CRUD + rotate-secret + active toggle,
  reveal-once secret dialog

TicketDetail: inline Popover editing for Status/Severity/Assignee
(replaces modal chain), AlertDialog delete confirmation, comment
draft autosave to localStorage per ticket.

Admin Users: window.confirm swapped for AlertDialog on delete +
rotate API key with toast feedback.

React Query hooks added for paged tickets, bulk actions,
notifications, webhooks, saved views, analytics, notification prefs.
ThemeProvider wired (v1.0 ships dark-only; toggle deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:20:28 -04:00
josh edf4c5eb3c Phase 2b: backend services, routes, and notification triggers
Attachments: multer-backed uploads with random-hex filenames,
streaming downloads with Content-Disposition, 25MB limit,
mimetype allowlist, audit entries, orphan cleanup on DB failure.

Full-text search: searchTicketIds + searchComments via raw SQL
ranked with ts_rank, composable filters via Prisma.sql/join,
hydrated with findMany and reordered via Map to preserve rank.

Pagination: listTicketsPaged returns {data,total,page,pageSize}
only when page/pageSize present (array response stays default,
so the Goddard n8n flow is unchanged).

Bulk actions: reassign/close/setSeverity/setStatus on POST /bulk,
writes one audit entry per ticket via createMany.

Analytics: summarize(window) runs 5 parallel groupBy + raw-SQL
queries for open-by-severity, status counts, queue load,
age buckets, percentile_cont median resolution hours.

CSV export streams matching tickets via res.write; saved views
CRUD with per-user ownership checks (403 cross-user, 404 missing).

Notifications: in-app Notification rows gated by prefs, email via
nodemailer (SMTP_HOST-gated, no-op when unset), outgoing webhooks
with HMAC-SHA256 signed POST and 3-retry exponential backoff.
Triggers wired into createTicket/updateTicket/addComment; mention
detection via parseMentions skips self-notify.

Infra: docker-compose uploads volume + SMTP env passthrough;
.env.example SMTP section.

43 server tests passing (attachment/webhook/notification/savedView
services covered; bulkAction covered in ticketService).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:56:33 -04:00
josh 0806aec4a4 Phase 2a: Prisma schema + shared schemas for v1.0 features
- New models: Attachment, Webhook, Notification, SavedView
- New fields: User.notificationPrefs (Json), indexes on Ticket
- post-push.sql manages the tsvector columns + GIN indexes + triggers for
  FTS on Ticket (title/overview/displayId) and Comment (body); Prisma can't
  express these
- package.json scripts: db:push and start:prod now chain `prisma db execute`
  against post-push.sql after `prisma db push`
- db:migrate script removed — project uses push workflow, not migrations
- Shared Zod schemas: attachment (25MB limit + mimetype allowlist), savedView,
  notification (prefs, mark-read, webhook CRUD)
- Shared type additions: Attachment, Notification, SavedView, Webhook,
  PaginatedResponse<T>
- Test fixtures updated for the new User.notificationPrefs column

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:52:16 -04:00
josh 77679922a8 Phase 1d: react-hook-form + zod on Login and NewTicket
- Both forms use useForm with zodResolver against shared schemas
  (loginSchema, createTicketSchema)
- Field-level errors rendered inline under inputs
- isSubmitting drives button disabled state
- NewTicket: severity registered with valueAsNumber; CTISelect wrapped in
  nested Controllers (one per categoryId/typeId/itemId) since it controls
  three form fields as a single compound input
- Admin forms stay on useState for now — they get redesigned with shadcn
  dialogs in Phase 3, RHF migration lands with that

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:29 -04:00
josh 17697ecf3b Phase 1c: shadcn/ui primitives + CSS-variable theme tokens
- components.json, @/* path alias in tsconfig + vite config
- Tailwind config: CSS-variable-backed color tokens, animation plugin
- index.css: :root (light) and .dark token blocks (slate base) — currently
  pinned to dark via class on <html> so visual appearance is unchanged
- src/lib/utils.ts: cn() helper (clsx + tailwind-merge)
- src/components/ui/: 16 primitives — button, input, label, textarea, badge,
  avatar, separator, skeleton, dialog, dropdown-menu, select, tabs, tooltip,
  sonner, alert-dialog, popover
- Nothing replaced yet; existing components still in place. Used in Phase 3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:19 -04:00
josh 4eae11b5b0 Phase 1b: React Query + Vitest on client
- @tanstack/react-query v5 with QueryClientProvider at app root
- client/src/api/queries.ts: query-key factory, hooks for tickets, ticket, audit,
  comments, users, CTI tree + cascade, plus full mutation set
  (create/update/delete ticket, add/delete comment, CTI CRUD, user CRUD)
- All page-level useEffect + useState fetching replaced:
  Dashboard, MyTickets, TicketDetail, NewTicket, admin/CTI, admin/Users
- Dashboard preserves 300ms debounced search via separate debouncedSearch state
- CTISelect cascades via useCategories / useTypes(categoryId) / useItems(typeId);
  dependent hooks disabled until parent selected
- vitest + @testing-library/react + jsdom; 6 client tests cover SeverityBadge + StatusBadge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:35:09 -04:00
josh aff52e5672 Phase 1a: shared schemas, service layer, server tooling
- shared/schemas/: move Zod schemas out of routes so client + server share them
- shared/types.ts: inferred types and enums for cross-package use
- server tsconfig rootDir raised to ".." so shared/ compiles in-tree
- server/src/services/: ticket, comment, cti, user, auth, notification (stub), search (stub)
- Routes thinned to validate-delegate-return; business logic now testable in isolation
- server/src/lib/httpError.ts: typed HttpError replaces ad-hoc throw shapes
- server/src/lib/logger.ts: pino structured logging replaces console.log
- autoClose job delegates to ticketService.closeStale()
- express-rate-limit on /api/auth/login (10 / 15min / IP)
- vitest + vitest-mock-extended; 20 service-level tests cover auth, ticket, comment, user flows
- CI: lint + test jobs before docker builds

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 15:34:57 -04:00
josh 27d2ab0f0d Add ESLint + Prettier + EditorConfig tooling at repo root
v1.0 Phase 1.1 — repo-wide lint/format baseline.

- eslint.config.mjs (flat config) lints server, client, shared
- .prettierrc.json, .prettierignore, .editorconfig, .nvmrc
- Root package.json holds shared devDeps; per-package scripts keep
  their typecheck + test runners
- Fix 7 lint issues surfaced by the baseline run:
  - TicketDetail.tsx: replace ternary-with-side-effects with if/else
  - admin/Users.tsx: escape apostrophe in JSX
  - errorHandler.ts: typed err as unknown with ErrorLike refinement
  - users.ts: Prisma.UserUpdateInput instead of Record<string, any>
  - seed.ts: drop unused goddard binding
- Run prettier across tracked sources for a clean formatting baseline
2026-04-18 14:47:34 -04:00
155 changed files with 19641 additions and 2673 deletions
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+11
View File
@@ -1,3 +1,5 @@
# Production — used with docker-compose (see server/.env.example for local dev)
# ── Registry ────────────────────────────────────────────────────────────────── # ── Registry ──────────────────────────────────────────────────────────────────
# Hostname of your container registry (no trailing slash) # Hostname of your container registry (no trailing slash)
REGISTRY=gitea.thewrightserver.net REGISTRY=gitea.thewrightserver.net
@@ -17,3 +19,12 @@ CLIENT_URL=http://tickets.thewrightserver.net
# Host port NPM proxies to — change if 3080 is taken # Host port NPM proxies to — change if 3080 is taken
PORT=3080 PORT=3080
# ── Email notifications (optional) ────────────────────────────────────────────
# Leave SMTP_HOST empty to disable outgoing email entirely.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=tickets@thewrightserver.net
SMTP_SECURE=false
+47 -6
View File
@@ -10,8 +10,8 @@ env:
OWNER: ${{ github.repository_owner }} OWNER: ${{ github.repository_owner }}
jobs: jobs:
typecheck-client: test-client:
name: TypeScript Check (client) name: Test (client)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -22,16 +22,55 @@ jobs:
with: with:
node-version: '22' node-version: '22'
- name: Install root dependencies
run: npm ci
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
working-directory: ./client working-directory: ./client
- name: Type check - name: Type check
run: npx tsc --noEmit run: npm run typecheck
working-directory: ./client working-directory: ./client
- name: Unit tests
run: npm test
working-directory: ./client
test-server:
name: Test (server)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install root dependencies
run: npm ci
- name: Install dependencies
run: npm ci
working-directory: ./server
- name: Prisma generate
run: npx prisma generate
working-directory: ./server
- name: Type check
run: npm run typecheck
working-directory: ./server
- name: Unit tests
run: npm test
working-directory: ./server
build-server: build-server:
name: Build Server name: Build Server
needs: test-server
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -50,7 +89,8 @@ jobs:
- name: Build and push server - name: Build and push server
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./server context: .
file: ./server/Dockerfile
push: true push: true
tags: | tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-server:latest
@@ -58,7 +98,7 @@ jobs:
build-client: build-client:
name: Build Client name: Build Client
needs: typecheck-client needs: test-client
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -77,7 +117,8 @@ jobs:
- name: Build and push client - name: Build and push client
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./client context: .
file: ./client/Dockerfile
push: true push: true
tags: | tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest ${{ env.REGISTRY }}/${{ env.OWNER }}/ticketing-client:latest
+7
View File
@@ -15,5 +15,12 @@ dist/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# IDE
.vscode/
.idea/
# Test coverage
coverage/
# Claude Code # Claude Code
.claude/ .claude/
+1
View File
@@ -0,0 +1 @@
22
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
build
.vite
coverage
*.min.js
*.min.css
package-lock.json
server/prisma/migrations
client/src/components/ui
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf"
}
+228 -85
View File
@@ -7,24 +7,32 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac
- **CTI routing** — tickets categorised by Category → Type → Item, reroutable at any time - **CTI routing** — tickets categorised by Category → Type → Item, reroutable at any time
- **Severity 15** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity - **Severity 15** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity
- **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days - **Status lifecycle** — Open → In Progress → Resolved → Closed; resolved tickets auto-close after 14 days
- **Queue filter** — filter dashboard by category (queue) - **Full-text search** — Postgres-native tsvector search across tickets and comments
- **My Tickets** — dedicated view of tickets assigned to you - **Saved views** — per-user filter presets on the tickets list
- **Comments** — threaded markdown comments per ticket with author avatars - **Pagination + bulk actions** — page through large result sets and reassign / close / reprioritise in batches
- **Roles** — Admin, Agent, User, Service (API key auth for automation) - **Attachments** — upload files on tickets and comments (25 MB each, local disk)
- **Notifications** — in-app bell + optional SMTP email on assignment, @mention, and resolution
- **Outgoing webhooks** — HMAC-signed POSTs on ticket/comment events, with per-endpoint secret and retries
- **@mentions** — autocomplete in comments; triggers notifications and deep-links to the mentioned user's queue
- **Analytics dashboard** — open-by-severity, aging tickets, resolution time, queue load
- **CSV export** — stream any filtered ticket view to CSV
- **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
- **Audit log** — every action tracked with actor, timestamp, and expandable detail - **Audit log** — every action tracked with actor, timestamp, and expandable detail
- **Admin panel** — manage users and the full CTI hierarchy via UI - **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 ## Roles
| Role | Access | | Role | Access |
|---|---| | --------- | ----------------------------------------------------------------------------------------- |
| **Admin** | Full access — manage users, CTI config, close and delete tickets | | **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) | | **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 | | **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login |
> Only **Admins** can manually set a ticket status to **Closed**. > Only **Admins** can manually set a ticket status to **Closed**.
@@ -60,6 +68,14 @@ POSTGRES_PASSWORD=<strong password>
JWT_SECRET=<output of: openssl rand -hex 64> JWT_SECRET=<output of: openssl rand -hex 64>
CLIENT_URL=http://tickets.thewrightserver.net CLIENT_URL=http://tickets.thewrightserver.net
PORT=3080 PORT=3080
# Optional — email notifications
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=<smtp password>
SMTP_FROM=tickets@thewrightserver.net
SMTP_SECURE=false
``` ```
Point NPM at `http://<host-ip>:3080` for the proxy host. Point NPM at `http://<host-ip>:3080` for the proxy host.
@@ -71,6 +87,8 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
The server exposes an unauthenticated `GET /healthz` that returns `{"status":"ok"}` and is polled by the compose healthcheck.
### 4. Seed (first deploy only) ### 4. Seed (first deploy only)
```bash ```bash
@@ -78,8 +96,22 @@ docker compose exec server npm run db:seed
``` ```
This creates: This creates:
- `admin` user (password: `admin123`) — **change this immediately** - `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
v1.0 is drop-in for existing deployments — the schema changes are all additive (new tables for attachments, webhooks, notifications, saved views; new columns for notification prefs and search vectors). No data migration is required.
```bash
docker compose pull
docker compose up -d
```
`npm run start:prod` runs `prisma db push` on boot, which applies the new tables and the search-index migration automatically. Set the new `SMTP_*` env vars if you want email notifications — otherwise they're silently skipped.
--- ---
@@ -108,9 +140,11 @@ docker run -d \
cd server cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install npm install
npm run db:migrate # creates tables 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 run dev # http://localhost:3000
npm test # vitest (service layer)
npm run typecheck
``` ```
### Client ### Client
@@ -119,16 +153,20 @@ npm run dev # http://localhost:3000
cd client cd client
npm install npm install
npm run dev # http://localhost:5173 (proxies /api to :3000) npm run dev # http://localhost:5173 (proxies /api to :3000)
npm test # vitest + testing-library
npm run typecheck
``` ```
CI runs typecheck + tests on both packages before building Docker images.
--- ---
## API Reference ## API Reference
All endpoints (except `/api/auth/*`) require authentication via one of: All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`) - **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` Base URL: `https://tickets.thewrightserver.net/api`
@@ -141,15 +179,23 @@ Base URL: `https://tickets.thewrightserver.net/api`
Authenticate and receive a JWT. Authenticate and receive a JWT.
**Body:** **Body:**
```json ```json
{ "username": "string", "password": "string" } { "username": "string", "password": "string" }
``` ```
**Response:** **Response:**
```json ```json
{ {
"token": "eyJ...", "token": "eyJ...",
"user": { "id": "...", "username": "admin", "displayName": "Admin", "email": "...", "role": "ADMIN" } "user": {
"id": "...",
"username": "admin",
"displayName": "Admin",
"email": "...",
"role": "ADMIN"
}
} }
``` ```
@@ -163,19 +209,33 @@ Returns the currently authenticated user.
#### `GET /api/tickets` #### `GET /api/tickets`
List all tickets, sorted by severity (ASC) then created date (DESC). List tickets, sorted by severity (ASC) then created date (DESC).
**Query parameters:** **Query parameters:**
| Parameter | Type | Description | | Parameter | Type | Description |
|---|---|---| | ------------ | ------ | --------------------------------------------------------------------- |
| `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` | | `status` | string | Filter by status: `OPEN`, `IN_PROGRESS`, `RESOLVED`, `CLOSED` |
| `severity` | number | Filter by severity: `1``5` | | `severity` | number | Filter by severity: `1``5` |
| `categoryId` | string | Filter by category (queue) | | `categoryId` | string | Filter by category (queue) |
| `assigneeId` | string | Filter by assignee user ID | | `assigneeId` | string | Filter by assignee user ID |
| `search` | string | Full-text search on title, overview, and display ID | | `createdById`| string | Filter by author |
| `search` | string | Full-text search (tsvector) on title, overview, and display ID |
| `page` | number | Page number, 1-indexed. When omitted the endpoint returns a bare array (v0.9 shape) |
| `pageSize` | number | Page size (default 25, max 100) |
**Response:** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. **Response (paginated):**
```json
{
"data": [ /* tickets */ ],
"total": 134,
"page": 1,
"pageSize": 25
}
```
**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.
--- ---
@@ -187,9 +247,10 @@ Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes
#### `POST /api/tickets` #### `POST /api/tickets`
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role. Create a new ticket. Requires **Agent** or **Admin** role.
**Body:** **Body:**
```json ```json
{ {
"title": "string", "title": "string",
@@ -208,35 +269,120 @@ Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
#### `PATCH /api/tickets/:id` #### `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. > Setting `status` to `CLOSED` requires **Admin** role.
**Body (all fields optional):**
```json
{
"title": "string",
"overview": "string (markdown)",
"severity": 3,
"status": "IN_PROGRESS",
"assigneeId": "string | null",
"categoryId": "string",
"typeId": "string",
"itemId": "string"
}
```
All changes are recorded in the audit log automatically. All changes are recorded in the audit log automatically.
**Response:** Updated ticket object.
--- ---
#### `DELETE /api/tickets/:id` #### `DELETE /api/tickets/:id`
Delete a ticket and all associated comments and audit logs. **Admin only.** Delete a ticket and all associated comments, audit logs, and attachments. **Admin only.**
**Response:** 204 No Content. ---
#### `POST /api/tickets/bulk`
Apply an action to many tickets at once. **Agent** or **Admin**.
```json
{ "ids": ["..."], "action": "reassign" | "close" | "setSeverity", "value": "..." }
```
---
#### `GET /api/export/tickets.csv`
Stream the matching tickets (same filter params as `GET /api/tickets`) as CSV.
---
### Search
#### `GET /api/search?q=<query>&limit=10`
Cross-resource search over tickets and comments using Postgres `plainto_tsquery` with rank ordering.
**Response:**
```json
{
"tickets": [ /* ticket summaries */ ],
"comments": [ /* comment snippets with ticket context */ ]
}
```
---
### Analytics
#### `GET /api/analytics/summary?window=30`
Returns aggregates for the given window (days — 14 / 30 / 90):
```json
{
"openBySeverity": { "1": 2, "2": 5, "3": 14, "4": 8, "5": 3 },
"aging": { "over7d": 6, "over14d": 2 },
"queueLoad": [ { "categoryId": "...", "name": "TheWrightServer", "open": 12 } ],
"medianResolutionHours": 9.4
}
```
---
### Saved Views
Per-user filter presets.
| Method | Endpoint | Description |
| -------- | ------------------------- | ------------------------ |
| `GET` | `/api/saved-views` | List the caller's views |
| `POST` | `/api/saved-views` | Create — `{ name, filters }` |
| `PATCH` | `/api/saved-views/:id` | Rename / edit filters |
| `DELETE` | `/api/saved-views/:id` | Delete |
---
### Notifications
| Method | Endpoint | Description |
| -------- | ------------------------------- | ---------------------------------------- |
| `GET` | `/api/notifications` | Caller's notifications, newest first |
| `POST` | `/api/notifications/read` | Mark specific or all — returns `{ updated }` |
Notifications are created on assignment, @mention, and status → RESOLVED. Email delivery is attempted when SMTP is configured and the target user has email notifications enabled in `User.notificationPrefs`.
---
### Attachments
| Method | Endpoint | Description |
| -------- | ------------------------------------------ | ---------------------------------- |
| `POST` | `/api/tickets/:id/attachments` | Upload (multipart, field `file`) |
| `POST` | `/api/comments/:id/attachments` | Upload on a comment |
| `GET` | `/api/attachments/:id` | Stream with original filename |
| `DELETE` | `/api/attachments/:id` | Uploader or Admin |
Size limit 25 MB per file. Storage path is controlled by `UPLOADS_DIR` (defaults to `./uploads` in dev, `/data/uploads` in the Docker image backed by the `uploads` volume).
---
### Webhooks
Admin-only. Outgoing webhooks fire on `ticket.created`, `ticket.status_changed`, `ticket.assigned`, and `comment.created`.
| Method | Endpoint | Description |
| -------- | --------------------------------------- | -------------------------- |
| `GET` | `/api/webhooks` | List |
| `POST` | `/api/webhooks` | Create — `{ name, url, events, secret? }` |
| `PATCH` | `/api/webhooks/:id` | Update |
| `DELETE` | `/api/webhooks/:id` | Delete |
| `POST` | `/api/webhooks/:id/rotate-secret` | Rotate secret |
**Signature:** each delivery includes `X-Ticketing-Signature: sha256=<hex>`, which is the HMAC-SHA256 of the raw request body using the webhook's secret. Retries up to 3× with exponential backoff on non-2xx responses.
--- ---
@@ -244,22 +390,15 @@ Delete a ticket and all associated comments and audit logs. **Admin only.**
#### `POST /api/tickets/:id/comments` #### `POST /api/tickets/:id/comments`
Add a comment to a ticket. Supports markdown. All authenticated roles may comment. Add a comment. Markdown body. @mentions are parsed and fire notifications to matched users.
**Body:**
```json ```json
{ "body": "string (markdown)" } { "body": "string (markdown)" }
``` ```
**Response:** Created comment object (201).
---
#### `DELETE /api/tickets/:id/comments/:commentId` #### `DELETE /api/tickets/:id/comments/:commentId`
Delete a comment. Authors may delete their own comments; Admins may delete any. Delete a comment. Authors may delete their own; Admins may delete any.
**Response:** 204 No Content.
--- ---
@@ -269,23 +408,10 @@ Delete a comment. Authors may delete their own comments; Admins may delete any.
Retrieve the full audit log for a ticket, ordered newest first. Retrieve the full audit log for a ticket, ordered newest first.
**Response:** Array of audit log entries:
```json
[
{
"id": "...",
"action": "COMMENT_ADDED",
"detail": "Comment body text here",
"createdAt": "2026-03-30T10:00:00Z",
"user": { "id": "...", "username": "admin", "displayName": "Admin" }
}
]
```
**Action types:** **Action types:**
| Action | Detail | | Action | Detail |
|---|---| | --------------------- | -------------------------------------------------------------- |
| `CREATED` | — | | `CREATED` | — |
| `STATUS_CHANGED` | e.g. `Open → In Progress` | | `STATUS_CHANGED` | e.g. `Open → In Progress` |
| `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` | | `SEVERITY_CHANGED` | e.g. `SEV 3 → SEV 1` |
@@ -295,6 +421,8 @@ Retrieve the full audit log for a ticket, ordered newest first.
| `OVERVIEW_CHANGED` | — | | `OVERVIEW_CHANGED` | — |
| `COMMENT_ADDED` | Comment body | | `COMMENT_ADDED` | Comment body |
| `COMMENT_DELETED` | Deleted comment body | | `COMMENT_DELETED` | Deleted comment body |
| `ATTACHMENT_ADDED` | Filename |
| `ATTACHMENT_REMOVED` | Filename |
--- ---
@@ -309,7 +437,7 @@ Read the CTI hierarchy. Used to resolve IDs when creating/rerouting tickets.
**Admin-only write operations:** **Admin-only write operations:**
| Method | Endpoint | Body | | Method | Endpoint | Body |
|---|---|---| | -------- | ------------------------- | ---------------------------------------------- |
| `POST` | `/api/cti/categories` | `{ "name": "string" }` | | `POST` | `/api/cti/categories` | `{ "name": "string" }` |
| `PUT` | `/api/cti/categories/:id` | `{ "name": "string" }` | | `PUT` | `/api/cti/categories/:id` | `{ "name": "string" }` |
| `DELETE` | `/api/cti/categories/:id` | — | | `DELETE` | `/api/cti/categories/:id` | — |
@@ -341,26 +469,16 @@ Create a user.
"username": "string", "username": "string",
"email": "string", "email": "string",
"displayName": "string", "displayName": "string",
"password": "string (not required for SERVICE role)", "password": "string (min 8 chars)",
"role": "ADMIN | AGENT | USER | SERVICE" "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` #### `PATCH /api/users/:id`
Update a user. Update a user — including `notificationPrefs` and `regenerateApiKey`.
```json
{
"displayName": "string",
"email": "string",
"password": "string",
"role": "ADMIN | AGENT | USER | SERVICE",
"regenerateApiKey": true
}
```
#### `DELETE /api/users/:id` #### `DELETE /api/users/:id`
@@ -368,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:** **Create a ticket from n8n:**
``` ```
POST /api/tickets POST /api/tickets
X-Api-Key: sk_<goddard api key> X-Api-Key: sk_<agent api key>
Content-Type: application/json Content-Type: application/json
{ {
@@ -386,27 +504,35 @@ Content-Type: application/json
"categoryId": "<TheWrightServer category ID>", "categoryId": "<TheWrightServer category ID>",
"typeId": "<Automation type ID>", "typeId": "<Automation type ID>",
"itemId": "<Backup item ID>", "itemId": "<Backup item ID>",
"assigneeId": "<Goddard user ID>" "assigneeId": "<agent user ID>"
} }
``` ```
CTI IDs can be fetched from: CTI IDs can be fetched from:
- `GET /api/cti/categories` - `GET /api/cti/categories`
- `GET /api/cti/types?categoryId=<id>` - `GET /api/cti/types?categoryId=<id>`
- `GET /api/cti/items?typeId=<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.
--- ---
## Environment Variables ## Environment Variables
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| | ------------------- | -------- | ---------------------------------------------------- |
| `DATABASE_URL` | Yes | PostgreSQL connection string | | `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` | | `JWT_SECRET` | Yes | Secret for signing JWTs — use `openssl rand -hex 64` |
| `CLIENT_URL` | Yes | Allowed CORS origin (your domain) | | `CLIENT_URL` | Yes | Allowed CORS origin (your domain) |
| `PORT` | No | Server port (default: `3000`) | | `PORT` | No | Server port (default: `3000`) |
| `UPLOADS_DIR` | No | Attachment storage path (default `/data/uploads` in Docker) |
| `SMTP_HOST` | No | SMTP host — when set, email notifications are sent |
| `SMTP_PORT` | No | SMTP port (default `587`) |
| `SMTP_USER` | No | SMTP username |
| `SMTP_PASS` | No | SMTP password |
| `SMTP_FROM` | No | From address (default `noreply@localhost`) |
| `SMTP_SECURE` | No | `true` for implicit TLS (port 465), else `false` |
| `REGISTRY` | Deploy | Container registry hostname | | `REGISTRY` | Deploy | Container registry hostname |
| `POSTGRES_PASSWORD` | Deploy | Postgres password | | `POSTGRES_PASSWORD` | Deploy | Postgres password |
| `TAG` | Deploy | Image tag to deploy (default: `latest`) | | `TAG` | Deploy | Image tag to deploy (default: `latest`) |
@@ -416,7 +542,7 @@ To regenerate the Goddard API key: Admin → Users → refresh icon next to Godd
## Ticket Severity ## Ticket Severity
| Level | Label | Meaning | | Level | Label | Meaning |
|---|---|---| | ----- | ----- | ------------------------------------ |
| 1 | SEV 1 | Critical — immediate action required | | 1 | SEV 1 | Critical — immediate action required |
| 2 | SEV 2 | High — significant impact | | 2 | SEV 2 | High — significant impact |
| 3 | SEV 3 | Medium — standard priority | | 3 | SEV 3 | Medium — standard priority |
@@ -437,3 +563,20 @@ OPEN → IN_PROGRESS → RESOLVED ──(14 days)──→ CLOSED
``` ```
> CLOSED status can only be set manually by an **Admin**. The auto-close job runs hourly. > CLOSED status can only be set manually by an **Admin**. The auto-close job runs hourly.
---
## Keyboard Shortcuts
| Context | Keys | Action |
| -------- | --------------------------------- | ---------------------------- |
| Global | `⌘K` / `Ctrl+K` | Command palette |
| Global | `?` | Shortcuts help |
| Global | `c` | Create ticket (Agent/Admin) |
| Navigate | `g` then `d` / `t` / `m` / `n` / `s` | Dashboard / Tickets / My Tickets / Notifications / Settings |
| List | `j` / `k` | Move cursor |
| List | `Enter` | Open focused ticket |
| List | `x` | Toggle selection |
| Detail | `e` | Edit title |
| Detail | `r` | Reply (focus composer) |
| Detail | `Ctrl+Enter` / `⌘Enter` | Submit comment |
+7 -5
View File
@@ -1,12 +1,14 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci COPY client/package*.json ./client/
COPY . . RUN npm ci --omit=dev && cd client && npm ci
RUN npm run build COPY client ./client
COPY shared ./shared
RUN cd client && npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/client/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY client/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+5 -1
View File
@@ -1,8 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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" />
<title>Ticketing System</title> <title>Ticketing System</title>
</head> </head>
<body> <body>
+4045 -72
View File
File diff suppressed because it is too large Load Diff
+30 -2
View File
@@ -6,11 +6,29 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.62.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -19,16 +37,26 @@
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"sonner": "^1.7.1",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-query-devtools": "^5.62.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^25.6.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^25.0.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.16", "tailwindcss": "^3.4.16",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.5" "vite": "^6.0.5",
"vitest": "^2.1.8"
} }
} }
+1 -1
View File
@@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2563eb"/>
<path fill="#ffffff" d="M128 144c0-8.8 7.2-16 16-16h224c8.8 0 16 7.2 16 16v48c0 8.8-7.2 16-16 16-8.8 0-16-7.2-16-16v-32H280v224h40c8.8 0 16 7.2 16 16s-7.2 16-16 16H192c-8.8 0-16-7.2-16-16s7.2-16 16-16h40V160h-72v32c0 8.8-7.2 16-16 16s-16-7.2-16-16v-48z"/>
</svg>

After

Width:  |  Height:  |  Size: 386 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Ticketing System",
"short_name": "Tickets",
"description": "Homelab ticketing with CTI routing, severity triage and n8n integration.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#2563eb",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+58
View File
@@ -0,0 +1,58 @@
// Minimal service worker: offline shell only. No data caching.
const CACHE = 'ticketing-shell-v1';
const SHELL = ['/', '/icon.svg', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL)),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
// Never cache API calls — they must always hit the server
if (url.pathname.startsWith('/api/')) return;
// Network-first for navigation, fall back to cached shell
if (req.mode === 'navigate') {
event.respondWith(
fetch(req)
.then((res) => {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put('/', clone));
return res;
})
.catch(() => caches.match('/')),
);
return;
}
// Cache-first for static assets
if (url.origin === self.location.origin) {
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;
return fetch(req).then((res) => {
if (res.ok && res.type === 'basic') {
const clone = res.clone();
caches.open(CACHE).then((c) => c.put(req, clone));
}
return res;
});
}),
);
}
});
+21 -12
View File
@@ -1,13 +1,17 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext';
import PrivateRoute from './components/PrivateRoute' import PrivateRoute from './components/PrivateRoute';
import AdminRoute from './components/AdminRoute' import AdminRoute from './components/AdminRoute';
import Login from './pages/Login' import Login from './pages/Login';
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard';
import MyTickets from './pages/MyTickets' import Tickets from './pages/tickets';
import TicketDetail from './pages/TicketDetail' import MyTickets from './pages/MyTickets';
import AdminUsers from './pages/admin/Users' import TicketDetail from './pages/ticket-detail';
import AdminCTI from './pages/admin/CTI' import Notifications from './pages/Notifications';
import Settings from './pages/Settings';
import AdminUsers from './pages/admin/Users';
import AdminCTI from './pages/admin/CTI';
import AdminWebhooks from './pages/admin/Webhooks';
export default function App() { export default function App() {
return ( return (
@@ -16,17 +20,22 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route element={<PrivateRoute />}> <Route element={<PrivateRoute />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/tickets" element={<Tickets />} />
<Route path="/my-tickets" element={<MyTickets />} /> <Route path="/my-tickets" element={<MyTickets />} />
<Route path="/notifications" element={<Notifications />} />
<Route path="/settings" element={<Settings />} />
<Route path="/:id" element={<TicketDetail />} /> <Route path="/:id" element={<TicketDetail />} />
<Route element={<AdminRoute />}> <Route element={<AdminRoute />}>
<Route path="/admin/users" element={<AdminUsers />} /> <Route path="/admin/users" element={<AdminUsers />} />
<Route path="/admin/cti" element={<AdminCTI />} /> <Route path="/admin/cti" element={<AdminCTI />} />
<Route path="/admin/webhooks" element={<AdminWebhooks />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </AuthProvider>
) );
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import axios from 'axios' import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: '/api',
}) });
export default api export default api;
+457
View File
@@ -0,0 +1,457 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import api from './client';
import { Ticket, Category, CTIType, Item, User, AuditLog, Comment } from '../types';
import type {
Notification,
SavedView,
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 ─────────────────────────────────────────────────────────────────────
export const qk = {
tickets: (filters?: Record<string, string | number | undefined>) =>
['tickets', filters ?? {}] as const,
ticketsPaged: (filters?: Record<string, string | number | undefined>) =>
['tickets-paged', filters ?? {}] as const,
ticket: (id: string) => ['ticket', id] as const,
ticketAudit: (id: string) => ['ticket', id, 'audit'] as const,
categories: () => ['cti', 'categories'] as const,
types: (categoryId?: string) => ['cti', 'types', categoryId ?? null] as const,
items: (typeId?: string) => ['cti', 'items', typeId ?? null] as const,
users: () => ['users'] as const,
notifications: (unread?: boolean) => ['notifications', { unread: !!unread }] as const,
webhooks: () => ['webhooks'] as const,
savedViews: () => ['saved-views'] as const,
analytics: (window: number) => ['analytics', window] as const,
};
// ── Helpers ─────────────────────────────────────────────────────────────────
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),
queryFn: async () => {
const res = await api.get<Ticket[]>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useTicketsPaged(params: Record<string, string | number | undefined> = {}) {
const clean = cleanParams(params);
return useQuery({
queryKey: qk.ticketsPaged(clean),
queryFn: async () => {
const res = await api.get<PaginatedResponse<Ticket>>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useBulkTickets() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids: string[]; action: string; value?: unknown }) =>
(await api.post('/tickets/bulk', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['tickets-paged'] });
},
});
}
export function useTicket(id: string | undefined) {
return useQuery({
queryKey: qk.ticket(id ?? ''),
queryFn: async () => (await api.get<Ticket>(`/tickets/${id}`)).data,
enabled: !!id,
});
}
export function useTicketAudit(id: string | undefined, enabled = true) {
return useQuery({
queryKey: qk.ticketAudit(id ?? ''),
queryFn: async () => (await api.get<AuditLog[]>(`/tickets/${id}/audit`)).data,
enabled: !!id && enabled,
});
}
export function useCreateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateTicketInput) =>
(await api.post<Ticket>('/tickets', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
export function useUpdateTicket() {
const qc = useQueryClient();
return useMutation({
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);
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['ticket', ticket.displayId, 'audit'] });
},
});
}
export function useDeleteTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/tickets/${id}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
// ── Comments ─────────────────────────────────────────────────────────────────
export function useAddComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: string) =>
(await api.post<Comment>(`/tickets/${ticketId}/comments`, { body })).data,
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
export function useDeleteComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (commentId: string) => {
await api.delete(`/tickets/${ticketId}/comments/${commentId}`);
},
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
// ── CTI ──────────────────────────────────────────────────────────────────────
export function useCategories() {
return useQuery({
queryKey: qk.categories(),
queryFn: async () => (await api.get<Category[]>('/cti/categories')).data,
staleTime: 5 * 60 * 1000,
});
}
export function useTypes(categoryId?: string) {
return useQuery({
queryKey: qk.types(categoryId),
queryFn: async () =>
(await api.get<CTIType[]>('/cti/types', { params: categoryId ? { categoryId } : {} })).data,
enabled: !!categoryId,
staleTime: 5 * 60 * 1000,
});
}
export function useItems(typeId?: string) {
return useQuery({
queryKey: qk.items(typeId),
queryFn: async () =>
(await api.get<Item[]>('/cti/items', { params: typeId ? { typeId } : {} })).data,
enabled: !!typeId,
staleTime: 5 * 60 * 1000,
});
}
function useCtiMutation<TVars>(fn: (vars: TVars) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries({ queryKey: ['cti'] }),
});
}
export function useCreateCategory() {
return useCtiMutation(async (data: { name: string }) =>
(await api.post<Category>('/cti/categories', data)).data,
);
}
export function useUpdateCategory() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Category>(`/cti/categories/${id}`, { name })).data,
);
}
export function useDeleteCategory() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/categories/${id}`);
});
}
export function useCreateType() {
return useCtiMutation(async (data: { name: string; categoryId: string }) =>
(await api.post<CTIType>('/cti/types', data)).data,
);
}
export function useUpdateType() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<CTIType>(`/cti/types/${id}`, { name })).data,
);
}
export function useDeleteType() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/types/${id}`);
});
}
export function useCreateItem() {
return useCtiMutation(async (data: { name: string; typeId: string }) =>
(await api.post<Item>('/cti/items', data)).data,
);
}
export function useUpdateItem() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Item>(`/cti/items/${id}`, { name })).data,
);
}
export function useDeleteItem() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/items/${id}`);
});
}
// ── Users ────────────────────────────────────────────────────────────────────
export function useUsers() {
return useQuery({
queryKey: qk.users(),
queryFn: async () => (await api.get<User[]>('/users')).data,
staleTime: 60 * 1000,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateUserInput) =>
(await api.post<User>('/users', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateUserInput }) =>
(await api.patch<User>(`/users/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/users/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
// ── Notifications ────────────────────────────────────────────────────────────
export function useNotifications(unread = false) {
return useQuery({
queryKey: qk.notifications(unread),
queryFn: async () =>
(
await api.get<Notification[]>('/notifications', {
params: unread ? { unread: 'true' } : {},
})
).data,
refetchInterval: 60_000,
});
}
export function useMarkNotificationsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { ids?: string[]; all?: boolean }) =>
(await api.post<{ updated: number }>('/notifications/read', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }),
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: async () =>
(await api.get<{ count: number }>('/notifications/unread-count')).data.count,
refetchInterval: 60_000,
});
}
// ── Webhooks ─────────────────────────────────────────────────────────────────
export function useWebhooks() {
return useQuery({
queryKey: qk.webhooks(),
queryFn: async () => (await api.get<Webhook[]>('/webhooks')).data,
});
}
export function useCreateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; url: string; events: string[] }) =>
(await api.post<Webhook>('/webhooks', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useUpdateWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateWebhookInput }) =>
(await api.patch<Webhook>(`/webhooks/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useDeleteWebhook() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/webhooks/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
export function useRotateWebhookSecret() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) =>
(await api.post<Webhook>(`/webhooks/${id}/rotate-secret`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }),
});
}
// ── Saved Views ──────────────────────────────────────────────────────────────
export function useSavedViews() {
return useQuery({
queryKey: qk.savedViews(),
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,
});
}
export function useCreateSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: CreateSavedViewInput) =>
(await api.post<SavedView>('/saved-views', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
export function useDeleteSavedView() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/saved-views/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }),
});
}
// ── Analytics ────────────────────────────────────────────────────────────────
export interface AnalyticsSummary {
windowDays: number;
openBySeverity: { severity: number; count: number }[];
statusCounts: { status: string; count: number }[];
queueByAssignee: { assigneeId: string | null; count: number }[];
ageBuckets: Record<'d1' | 'd7' | 'd14' | 'older', number>;
medianResolutionHours: number | null;
}
export function useAnalytics(windowDays = 30) {
return useQuery({
queryKey: qk.analytics(windowDays),
queryFn: async () =>
(
await api.get<AnalyticsSummary>('/analytics/summary', {
params: { window: windowDays },
})
).data,
staleTime: 60_000,
});
}
// ── Notification prefs ───────────────────────────────────────────────────────
export interface NotificationPrefs {
email: { assignment: boolean; mention: boolean; resolved: boolean };
inApp: { assignment: boolean; mention: boolean; resolved: boolean };
}
export function useNotificationPrefs() {
return useQuery({
queryKey: ['notifications', 'prefs'],
queryFn: async () => (await api.get<NotificationPrefs>('/notifications/prefs')).data,
});
}
export function useUpdateNotificationPrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (prefs: NotificationPrefs) =>
(await api.put<NotificationPrefs>('/notifications/prefs', prefs)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', 'prefs'] }),
});
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
},
},
});
+4 -4
View File
@@ -1,7 +1,7 @@
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext';
export default function AdminRoute() { export default function AdminRoute() {
const { user } = useAuth() const { user } = useAuth();
return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace /> return user?.role === 'ADMIN' ? <Outlet /> : <Navigate to="/" replace />;
} }
+17 -11
View File
@@ -1,14 +1,20 @@
const PALETTE = [ const PALETTE = [
'#ef4444', '#f97316', '#f59e0b', '#10b981', '#ef4444',
'#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899', '#f97316',
] '#f59e0b',
'#10b981',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#ec4899',
];
function nameToColor(name: string): string { function nameToColor(name: string): string {
let hash = 0 let hash = 0;
for (let i = 0; i < name.length; i++) { for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash) hash = name.charCodeAt(i) + ((hash << 5) - hash);
} }
return PALETTE[Math.abs(hash) % PALETTE.length] return PALETTE[Math.abs(hash) % PALETTE.length];
} }
function initials(name: string): string { function initials(name: string): string {
@@ -17,18 +23,18 @@ function initials(name: string): string {
.slice(0, 2) .slice(0, 2)
.map((n) => n[0]) .map((n) => n[0])
.join('') .join('')
.toUpperCase() .toUpperCase();
} }
const SIZES = { const SIZES = {
sm: 'w-6 h-6 text-xs', sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm', md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base', lg: 'w-10 h-10 text-base',
} };
interface AvatarProps { interface AvatarProps {
name: string name: string;
size?: keyof typeof SIZES size?: keyof typeof SIZES;
} }
export default function Avatar({ name, size = 'md' }: AvatarProps) { export default function Avatar({ name, size = 'md' }: AvatarProps) {
@@ -40,5 +46,5 @@ export default function Avatar({ name, size = 'md' }: AvatarProps) {
> >
{initials(name)} {initials(name)}
</div> </div>
) );
} }
+66 -46
View File
@@ -1,62 +1,82 @@
import { useEffect, useState } from 'react' import { useCategories, useItems, useTypes } from '../api/queries';
import api from '../api/client'
import { Category, CTIType, Item } from '../types'
interface CTISelectProps { interface CTISelectProps {
value: { categoryId: string; typeId: string; itemId: string } value: { categoryId: string; typeId: string; itemId: string };
onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void onChange: (value: { categoryId: string; typeId: string; itemId: string }) => void;
disabled?: boolean disabled?: boolean;
compact?: boolean;
} }
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) { export default function CTISelect({ value, onChange, disabled, compact }: CTISelectProps) {
const [categories, setCategories] = useState<Category[]>([]) const { data: categories = [] } = useCategories();
const [types, setTypes] = useState<CTIType[]>([]) const { data: types = [] } = useTypes(value.categoryId || undefined);
const [items, setItems] = useState<Item[]>([]) const { data: items = [] } = useItems(value.typeId || undefined);
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
}, [])
useEffect(() => {
if (!value.categoryId) {
setTypes([])
setItems([])
return
}
api
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
.then((r) => setTypes(r.data))
}, [value.categoryId])
useEffect(() => {
if (!value.typeId) {
setItems([])
return
}
api
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
.then((r) => setItems(r.data))
}, [value.typeId])
const handleCategory = (categoryId: string) => { const handleCategory = (categoryId: string) => {
onChange({ categoryId, typeId: '', itemId: '' }) onChange({ categoryId, typeId: '', itemId: '' });
} };
const handleType = (typeId: string) => { const handleType = (typeId: string) => {
onChange({ ...value, typeId, itemId: '' }) onChange({ ...value, typeId, itemId: '' });
} };
const handleItem = (itemId: string) => { const handleItem = (itemId: string) => {
onChange({ ...value, itemId }) onChange({ ...value, itemId });
} };
const selectClass = 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 ( return (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div> <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 <select
value={value.categoryId} value={value.categoryId}
onChange={(e) => handleCategory(e.target.value)} onChange={(e) => handleCategory(e.target.value)}
@@ -73,7 +93,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<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 <select
value={value.typeId} value={value.typeId}
onChange={(e) => handleType(e.target.value)} onChange={(e) => handleType(e.target.value)}
@@ -90,7 +110,7 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</div> </div>
<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 <select
value={value.itemId} value={value.itemId}
onChange={(e) => handleItem(e.target.value)} onChange={(e) => handleItem(e.target.value)}
@@ -106,5 +126,5 @@ export default function CTISelect({ value, onChange, disabled }: CTISelectProps)
</select> </select>
</div> </div>
</div> </div>
) );
} }
+236
View File
@@ -0,0 +1,236 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
ListChecks,
User as UserIcon,
Bell,
Settings as SettingsIcon,
Shield,
Plus,
LogOut,
Keyboard,
Ticket as TicketIcon,
} from 'lucide-react';
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from '@/components/ui/command';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { useAuth } from '../contexts/AuthContext';
import { useUsers } from '../api/queries';
import api from '../api/client';
import type { Ticket } from '../types';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onNewTicket?: () => void;
onShowShortcuts?: () => void;
}
export default function CommandPalette({
open,
onOpenChange,
onNewTicket,
onShowShortcuts,
}: Props) {
const navigate = useNavigate();
const { user, logout } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [query, setQuery] = useState('');
const [tickets, setTickets] = useState<Ticket[]>([]);
const { data: users = [] } = useUsers();
// Debounced ticket search
useEffect(() => {
if (!open) return;
const q = query.trim();
if (q.length < 2) {
setTickets([]);
return;
}
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', { params: { q, limit: 8 } });
setTickets(res.data.tickets.slice(0, 8));
} catch {
setTickets([]);
}
}, 180);
return () => clearTimeout(t);
}, [query, open]);
// Reset when closed
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const go = (path: string) => {
onOpenChange(false);
navigate(path);
};
const run = (fn: () => void) => {
onOpenChange(false);
setTimeout(fn, 0);
};
const userMatches = useMemo(
() =>
query.trim().length === 0
? []
: users
.filter(
(u) =>
u.displayName.toLowerCase().includes(query.toLowerCase()) ||
u.username.toLowerCase().includes(query.toLowerCase()),
)
.slice(0, 6),
[users, query],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="p-0 gap-0 overflow-hidden max-w-xl">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search tickets, jump to pages, run commands…"
value={query}
onValueChange={setQuery}
autoFocus
/>
<CommandList>
<CommandEmpty>
{query.trim().length < 2 ? 'Keep typing to search…' : 'No results.'}
</CommandEmpty>
{tickets.length > 0 && (
<CommandGroup heading="Tickets">
{tickets.map((t) => (
<CommandItem
key={t.id}
value={`ticket-${t.id}`}
onSelect={() => go(`/${t.displayId}`)}
>
<TicketIcon size={14} className="opacity-60" />
<span className="font-mono text-xs text-muted-foreground mr-1">
{t.displayId}
</span>
<span className="truncate">{t.title}</span>
</CommandItem>
))}
</CommandGroup>
)}
{userMatches.length > 0 && (
<>
{tickets.length > 0 && <CommandSeparator />}
<CommandGroup heading="People">
{userMatches.map((u) => (
<CommandItem
key={u.id}
value={`user-${u.id}`}
onSelect={() =>
go(`/tickets?assigneeId=${encodeURIComponent(u.id)}`)
}
>
<UserIcon size={14} className="opacity-60" />
<span>{u.displayName}</span>
<span className="ml-auto text-xs text-muted-foreground">
tickets assigned
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{(tickets.length > 0 || userMatches.length > 0) && <CommandSeparator />}
<CommandGroup heading="Actions">
{onNewTicket && (
<CommandItem
value="new-ticket"
onSelect={() => run(() => onNewTicket())}
>
<Plus size={14} className="opacity-60" />
New ticket
<span className="ml-auto text-xs text-muted-foreground">c</span>
</CommandItem>
)}
<CommandItem value="nav-dashboard" onSelect={() => go('/dashboard')}>
<LayoutDashboard size={14} className="opacity-60" />
Go to dashboard
<span className="ml-auto text-xs text-muted-foreground">g d</span>
</CommandItem>
<CommandItem value="nav-tickets" onSelect={() => go('/tickets')}>
<ListChecks size={14} className="opacity-60" />
Go to tickets
<span className="ml-auto text-xs text-muted-foreground">g t</span>
</CommandItem>
<CommandItem value="nav-my" onSelect={() => go('/my-tickets')}>
<UserIcon size={14} className="opacity-60" />
My tickets
<span className="ml-auto text-xs text-muted-foreground">g m</span>
</CommandItem>
<CommandItem value="nav-notifications" onSelect={() => go('/notifications')}>
<Bell size={14} className="opacity-60" />
Notifications
<span className="ml-auto text-xs text-muted-foreground">g n</span>
</CommandItem>
<CommandItem value="nav-settings" onSelect={() => go('/settings')}>
<SettingsIcon size={14} className="opacity-60" />
Settings
</CommandItem>
</CommandGroup>
{isAdmin && (
<>
<CommandSeparator />
<CommandGroup heading="Admin">
<CommandItem value="admin-users" onSelect={() => go('/admin/users')}>
<Shield size={14} className="opacity-60" />
Manage users
</CommandItem>
<CommandItem value="admin-cti" onSelect={() => go('/admin/cti')}>
<Shield size={14} className="opacity-60" />
Manage CTI
</CommandItem>
<CommandItem value="admin-webhooks" onSelect={() => go('/admin/webhooks')}>
<Shield size={14} className="opacity-60" />
Manage webhooks
</CommandItem>
</CommandGroup>
</>
)}
<CommandSeparator />
<CommandGroup heading="System">
{onShowShortcuts && (
<CommandItem
value="show-shortcuts"
onSelect={() => run(() => onShowShortcuts())}
>
<Keyboard size={14} className="opacity-60" />
Keyboard shortcuts
<span className="ml-auto text-xs text-muted-foreground">?</span>
</CommandItem>
)}
<CommandItem value="logout" onSelect={() => run(logout)}>
<LogOut size={14} className="opacity-60" />
Log out
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import api from '../api/client';
import type { Ticket } from '../types';
export default function GlobalSearch() {
const navigate = useNavigate();
const [q, setQ] = useState('');
const [open, setOpen] = useState(false);
const [results, setResults] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
useEffect(() => {
if (!q.trim()) {
setResults([]);
return;
}
setLoading(true);
const t = setTimeout(async () => {
try {
const res = await api.get<{ tickets: Ticket[] }>('/search', {
params: { q, limit: 8 },
});
setResults(res.data.tickets ?? []);
} catch {
setResults([]);
} finally {
setLoading(false);
}
}, 200);
return () => clearTimeout(t);
}, [q]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!q.trim()) return;
setOpen(false);
navigate(`/tickets?search=${encodeURIComponent(q)}`);
};
return (
<div ref={wrapperRef} className="relative flex-1 max-w-md">
<form onSubmit={handleSubmit}>
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onFocus={() => setOpen(true)}
placeholder="Search tickets"
className="w-full pl-9 pr-3 py-1.5 rounded-md text-sm bg-background border border-input text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</form>
{open && q.trim() && (
<div className="absolute top-full mt-1 left-0 right-0 z-40 bg-popover border rounded-md shadow-lg overflow-hidden">
{loading ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">Searching</p>
) : results.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground text-center">No matches</p>
) : (
<ul className="max-h-80 overflow-auto">
{results.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => {
setOpen(false);
setQ('');
navigate(`/${t.displayId}`);
}}
className="w-full text-left px-3 py-2 hover:bg-accent flex flex-col gap-0.5"
>
<span className="text-sm text-foreground line-clamp-1">{t.title}</span>
<span className="text-xs text-muted-foreground font-mono">
{t.displayId} · {t.status}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const GROUPS: { title: string; items: [string, string][] }[] = [
{
title: 'Global',
items: [
['⌘/Ctrl K', 'Open command palette'],
['?', 'Show this help'],
['c', 'Create new ticket'],
],
},
{
title: 'Navigate',
items: [
['g d', 'Dashboard'],
['g t', 'Tickets'],
['g m', 'My tickets'],
['g n', 'Notifications'],
['g s', 'Settings'],
],
},
{
title: 'Tickets list',
items: [
['j', 'Next ticket'],
['k', 'Previous ticket'],
['Enter', 'Open selected'],
['x', 'Toggle selected'],
],
},
{
title: 'Ticket detail',
items: [
['e', 'Edit title & overview'],
['r', 'Reply / new comment'],
['⌘/Ctrl Enter', 'Submit comment'],
],
},
];
export default function KeyboardHelp({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Keyboard shortcuts</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{GROUPS.map((g) => (
<div key={g.title}>
<p className="text-xs uppercase tracking-wide text-muted-foreground mb-2">
{g.title}
</p>
<dl className="space-y-1.5">
{g.items.map(([keys, label]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt className="text-foreground">{label}</dt>
<dd>
<kbd className="font-mono text-[11px] bg-muted border border-border rounded px-1.5 py-0.5 text-muted-foreground">
{keys}
</kbd>
</dd>
</div>
))}
</dl>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+238 -75
View File
@@ -1,100 +1,263 @@
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, Users, Settings, LogOut, Plus, Ticket } from 'lucide-react' import {
import { useAuth } from '../contexts/AuthContext' Plus,
import NewTicketModal from '../pages/NewTicket' LogOut,
Settings as SettingsIcon,
Users as UsersIcon,
Webhook,
Menu,
X,
SlidersHorizontal,
Keyboard,
} from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import NewTicketModal from '../pages/NewTicket';
import NotificationsBell from './NotificationsBell';
import GlobalSearch from './GlobalSearch';
import Avatar from './Avatar';
import CommandPalette from './CommandPalette';
import KeyboardHelp from './KeyboardHelp';
import { useShortcut, useLeaderShortcut } from '../hooks/useShortcuts';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface LayoutProps { interface LayoutProps {
children: ReactNode children: ReactNode;
title?: string title?: string;
action?: ReactNode action?: ReactNode;
subheader?: ReactNode;
wide?: boolean;
} }
export default function Layout({ children, title, action }: LayoutProps) { const navBase =
const { user, logout } = useAuth() 'px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground';
const location = useLocation()
const navigate = useNavigate()
const [showNewTicket, setShowNewTicket] = useState(false)
const canCreateTicket = user?.role !== 'USER' export default function Layout({ children, title, action, subheader, wide }: LayoutProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [showNewTicket, setShowNewTicket] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const navItems = [ const canCreateTicket = user?.role !== 'USER';
{ to: '/', icon: LayoutDashboard, label: 'All Tickets' }, const isAdmin = user?.role === 'ADMIN';
{ to: '/my-tickets', icon: Ticket, label: 'My Tickets' },
...(user?.role === 'ADMIN'
? [
{ to: '/admin/users', icon: Users, label: 'Users' },
{ to: '/admin/cti', icon: Settings, label: 'CTI Config' },
]
: []),
]
const handleLogout = () => { const handleLogout = () => {
logout() logout();
navigate('/login') navigate('/login');
} };
const isActive = (to: string) => useShortcut('mod+k', (e) => {
to === '/' ? location.pathname === '/' : location.pathname.startsWith(to) e.preventDefault();
setPaletteOpen((v) => !v);
});
useShortcut('shift+/', (e) => {
// ? key — open help overlay
e.preventDefault();
setHelpOpen(true);
});
useShortcut('c', (e) => {
if (!canCreateTicket) return;
e.preventDefault();
setShowNewTicket(true);
}, [canCreateTicket]);
useLeaderShortcut('g', {
d: () => navigate('/dashboard'),
t: () => navigate('/tickets'),
m: () => navigate('/my-tickets'),
n: () => navigate('/notifications'),
s: () => navigate('/settings'),
});
const primaryNav = (
<>
<NavLink
to="/dashboard"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Dashboard
</NavLink>
<NavLink
to="/tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
Tickets
</NavLink>
<NavLink
to="/my-tickets"
className={({ isActive }) =>
`${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`
}
>
My tickets
</NavLink>
</>
);
return ( return (
<div className="flex h-screen bg-gray-950 overflow-hidden"> <div className="min-h-screen bg-background text-foreground flex flex-col">
{/* Sidebar */} {/* Top nav */}
<aside className="w-60 bg-gray-900 border-r border-gray-800 flex flex-col flex-shrink-0"> <header className="sticky top-0 z-30 flex-shrink-0 border-b border-border bg-card/95 backdrop-blur">
<div className="px-5 py-4 border-b border-gray-800"> <div className="mx-auto max-w-[1400px] 2xl:max-w-[1800px] px-4 h-12 flex items-center gap-4">
<h1 className="text-sm font-bold text-white tracking-wide">Ticketing</h1> <Link
<p className="text-xs text-gray-500 mt-0.5">{user?.displayName}</p> to="/dashboard"
className="flex items-center gap-2 font-semibold text-sm whitespace-nowrap"
>
<span className="w-6 h-6 rounded bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
T
</span>
<span className="hidden sm:inline">Ticketing</span>
</Link>
<nav className="hidden md:flex items-center gap-1">{primaryNav}</nav>
<div className="flex-1 hidden md:block">
<GlobalSearch />
</div> </div>
<nav className="flex-1 p-2 space-y-0.5"> <div className="flex items-center gap-1 ml-auto">
{navItems.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
isActive(to)
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
}`}
>
<Icon size={15} />
{label}
</Link>
))}
</nav>
<div className="p-2 border-t border-gray-800 space-y-0.5">
{canCreateTicket && ( {canCreateTicket && (
<button <button
onClick={() => setShowNewTicket(true)} onClick={() => setShowNewTicket(true)}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors" className="hidden sm:flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm font-medium hover:opacity-90 transition-opacity"
> >
<Plus size={15} /> <Plus size={14} />
New Ticket New
</button> </button>
)} )}
<button <NotificationsBell />
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-gray-400 hover:bg-gray-800 hover:text-gray-100 w-full transition-colors"
>
<LogOut size={15} />
Logout
</button>
</div>
</aside>
{/* Main */} <DropdownMenu>
<div className="flex-1 flex flex-col overflow-hidden"> <DropdownMenuTrigger asChild>
{(title || action) && ( <button
<header className="bg-gray-900 border-b border-gray-800 px-6 py-4 flex items-center justify-between flex-shrink-0"> type="button"
{title && <h2 className="text-base font-semibold text-gray-100">{title}</h2>} aria-label="User menu"
{action && <div>{action}</div>} className="p-1 rounded-md hover:bg-accent transition-colors"
</header> >
)} <Avatar name={user?.displayName ?? '?'} size="sm" />
<main className="flex-1 overflow-auto p-6">{children}</main> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-2">
<p className="text-sm font-semibold">{user?.displayName}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div> </div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/settings" className="flex items-center gap-2 w-full">
<SettingsIcon size={14} />
Settings
</Link>
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/admin/users" className="flex items-center gap-2 w-full">
<UsersIcon size={14} />
Users
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/cti" className="flex items-center gap-2 w-full">
<SlidersHorizontal size={14} />
CTI config
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/webhooks" className="flex items-center gap-2 w-full">
<Webhook size={14} />
Webhooks
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setHelpOpen(true)}>
<Keyboard size={14} />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout} className="text-destructive">
<LogOut size={14} />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
className="md:hidden p-2 rounded-md text-muted-foreground hover:bg-accent"
aria-label="Menu"
>
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-border bg-card px-4 py-3 space-y-2">
<div className="flex flex-col gap-1">{primaryNav}</div>
<GlobalSearch />
{canCreateTicket && (
<button
onClick={() => {
setShowNewTicket(true);
setMobileOpen(false);
}}
className="w-full flex items-center justify-center gap-1.5 bg-primary text-primary-foreground px-3 py-2 rounded-md text-sm font-medium"
>
<Plus size={14} />
New ticket
</button>
)}
</div>
)}
</header>
{/* Optional sub-header */}
{(title || action || subheader) && (
<div className="border-b border-border bg-card/50">
<div
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 && (
<h1 className="text-lg font-semibold text-foreground truncate">{title}</h1>
)}
{subheader}
</div>
{action && <div className="flex items-center gap-2">{action}</div>}
</div>
</div>
)}
<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>
{showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />} {showNewTicket && <NewTicketModal onClose={() => setShowNewTicket(false)} />}
<CommandPalette
open={paletteOpen}
onOpenChange={setPaletteOpen}
onNewTicket={canCreateTicket ? () => setShowNewTicket(true) : undefined}
onShowShortcuts={() => setHelpOpen(true)}
/>
<KeyboardHelp open={helpOpen} onOpenChange={setHelpOpen} />
</div> </div>
) );
} }
+158
View File
@@ -0,0 +1,158 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import type { User } from '../types';
interface Props extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'> {
value: string;
onChange: (value: string) => void;
users: Pick<User, 'id' | 'username' | 'displayName'>[];
/** Called when user hits ⌘/Ctrl+Enter with no menu open. */
onSubmit?: () => void;
}
interface Trigger {
start: number;
query: string;
}
const MENTION_RE = /(^|[^\w-])@([\w-]{0,30})$/;
function detectTrigger(text: string, caret: number): Trigger | null {
const prefix = text.slice(0, caret);
const m = MENTION_RE.exec(prefix);
if (!m) return null;
// position of '@' is caret - matched[2].length - 1
return { start: caret - m[2].length - 1, query: m[2] };
}
const MentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function MentionTextarea(
{ value, onChange, users, onSubmit, onKeyDown, ...rest },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement | null>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement);
const [trigger, setTrigger] = useState<Trigger | null>(null);
const [cursor, setCursor] = useState(0);
const matches = useMemo(() => {
if (!trigger) return [];
const q = trigger.query.toLowerCase();
const filter = (u: (typeof users)[number]) =>
u.username.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q);
return (q ? users.filter(filter) : users).slice(0, 6);
}, [trigger, users]);
useEffect(() => {
setCursor(0);
}, [trigger?.query]);
const handleSelect = () => {
const el = innerRef.current;
if (!el) return;
const t = detectTrigger(value, el.selectionStart ?? 0);
setTrigger(t);
};
const insertMention = (username: string) => {
const el = innerRef.current;
if (!el || !trigger) return;
const before = value.slice(0, trigger.start);
const after = value.slice(el.selectionStart ?? trigger.start);
const insertion = `@${username} `;
const next = before + insertion + after;
onChange(next);
setTrigger(null);
// move caret after inserted mention
const newCaret = before.length + insertion.length;
requestAnimationFrame(() => {
if (!innerRef.current) return;
innerRef.current.focus();
innerRef.current.setSelectionRange(newCaret, newCaret);
});
};
const keyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (trigger && matches.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setCursor((c) => (c + 1) % matches.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setCursor((c) => (c - 1 + matches.length) % matches.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
insertMention(matches[cursor].username);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setTrigger(null);
return;
}
}
if (onSubmit && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onSubmit();
return;
}
onKeyDown?.(e);
};
return (
<div className="relative">
<textarea
{...rest}
ref={innerRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
// trigger recalc via select event after React updates
requestAnimationFrame(handleSelect);
}}
onKeyUp={handleSelect}
onClick={handleSelect}
onBlur={() => setTimeout(() => setTrigger(null), 120)}
onKeyDown={keyDown}
/>
{trigger && matches.length > 0 && (
<div
className="absolute left-0 top-full mt-1 z-20 w-56 rounded-md border border-border bg-popover text-popover-foreground shadow-lg overflow-hidden"
role="listbox"
>
{matches.map((u, i) => (
<button
type="button"
key={u.id}
role="option"
aria-selected={i === cursor}
onMouseDown={(e) => {
e.preventDefault();
insertMention(u.username);
}}
onMouseEnter={() => setCursor(i)}
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 ${
i === cursor ? 'bg-accent text-accent-foreground' : ''
}`}
>
<span className="font-medium">{u.displayName}</span>
<span className="text-xs text-muted-foreground">@{u.username}</span>
</button>
))}
</div>
)}
</div>
);
});
export default MentionTextarea;
+19 -18
View File
@@ -1,39 +1,40 @@
import { ReactNode, useEffect } from 'react' import { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react' import { X } from 'lucide-react';
interface ModalProps { interface ModalProps {
title: string title: string;
onClose: () => void onClose: () => void;
children: ReactNode children: ReactNode;
size?: 'md' | 'lg' size?: 'md' | 'lg';
} }
export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) { export default function Modal({ title, onClose, children, size = 'md' }: ModalProps) {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose();
} };
document.addEventListener('keydown', handler) document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler);
}, [onClose]) }, [onClose]);
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={`bg-gray-900 border border-gray-800 rounded-xl shadow-2xl w-full mx-4 ${size === 'lg' ? 'max-w-2xl' : 'max-w-md'}`}
> >
<div className={`bg-gray-900 border border-gray-800 rounded-xl shadow-2xl w-full mx-4 ${size === 'lg' ? 'max-w-2xl' : 'max-w-md'}`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-800"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-gray-100">{title}</h3> <h3 className="text-base font-semibold text-gray-100">{title}</h3>
<button <button onClick={onClose} className="text-gray-500 hover:text-gray-300 transition-colors">
onClick={onClose}
className="text-gray-500 hover:text-gray-300 transition-colors"
>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className="px-6 py-5">{children}</div> <div className="px-6 py-5">{children}</div>
</div> </div>
</div> </div>
) );
} }
+113
View File
@@ -0,0 +1,113 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Bell } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
useNotifications,
useUnreadCount,
useMarkNotificationsRead,
} from '../api/queries';
import type { Notification } from '../../../shared/types';
const KIND_LABELS: Record<string, string> = {
ASSIGNED: 'assigned to you',
MENTION: 'mentioned you',
RESOLVED: 'was resolved',
TICKET_CREATED: 'was created',
STATUS_CHANGED: 'status changed',
COMMENT: 'new comment',
};
interface NotifData {
displayId?: string;
title?: string;
byName?: string;
}
function renderSummary(n: Notification): { label: string; href: string } {
const data = (n.data ?? {}) as NotifData;
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
const label = data.title
? `${data.title}${action}`
: `Ticket ${action}`;
const href = data.displayId ? `/${data.displayId}` : '/notifications';
return { label, href };
}
export default function NotificationsBell() {
const { data: unreadCount = 0 } = useUnreadCount();
const { data: notifications = [] } = useNotifications();
const markRead = useMarkNotificationsRead();
const latest = useMemo(() => notifications.slice(0, 8), [notifications]);
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Notifications"
className="relative p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 min-w-[1rem] h-4 px-1 rounded-full bg-destructive text-destructive-foreground text-[10px] font-semibold flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-semibold">Notifications</span>
{unreadCount > 0 && (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-xs text-primary hover:underline"
>
Mark all read
</button>
)}
</div>
<div className="max-h-96 overflow-auto">
{latest.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
You&apos;re all caught up
</p>
) : (
latest.map((n) => {
const { label, href } = renderSummary(n);
const unread = !n.readAt;
return (
<Link
key={n.id}
to={href}
onClick={() => unread && markRead.mutate({ ids: [n.id] })}
className={`flex flex-col gap-1 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-accent transition-colors ${
unread ? 'bg-primary/5' : ''
}`}
>
<span className="line-clamp-2">{label}</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</span>
</Link>
);
})
)}
</div>
<Link
to="/notifications"
className="block px-3 py-2 text-center text-xs text-primary hover:underline border-t"
>
View all notifications
</Link>
</PopoverContent>
</Popover>
);
}
+5 -5
View File
@@ -1,16 +1,16 @@
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext';
export default function PrivateRoute() { export default function PrivateRoute() {
const { user, loading } = useAuth() const { user, loading } = useAuth();
if (loading) { if (loading) {
return ( return (
<div className="flex h-screen items-center justify-center bg-gray-50"> <div className="flex h-screen items-center justify-center bg-gray-50">
<div className="text-gray-500">Loading...</div> <div className="text-gray-500">Loading...</div>
</div> </div>
) );
} }
return user ? <Outlet /> : <Navigate to="/login" replace /> return user ? <Outlet /> : <Navigate to="/login" replace />;
} }
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import SeverityBadge from './SeverityBadge';
describe('SeverityBadge', () => {
it('renders the severity label', () => {
render(<SeverityBadge severity={1} />);
expect(screen.getByText('SEV 1')).toBeInTheDocument();
});
it('falls back to SEV 5 styling for out-of-range values', () => {
render(<SeverityBadge severity={99 as number} />);
expect(screen.getByText('SEV 5')).toBeInTheDocument();
});
});
+6 -10
View File
@@ -1,16 +1,12 @@
const config: Record<number, { label: string; className: string }> = { import { SEVERITY_BADGE } from '../lib/severityColors';
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' },
}
export default function SeverityBadge({ severity }: { severity: number }) { export default function SeverityBadge({ severity }: { severity: number }) {
const { label, className } = config[severity] ?? config[5] const { label, className } = SEVERITY_BADGE[severity] ?? SEVERITY_BADGE[5];
return ( return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}> <span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-semibold border ${className}`}
>
{label} {label}
</span> </span>
) );
} }
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import StatusBadge from './StatusBadge';
describe('StatusBadge', () => {
it.each([
['OPEN', 'Open'],
['IN_PROGRESS', 'In Progress'],
['RESOLVED', 'Resolved'],
['CLOSED', 'Closed'],
] as const)('renders %s as "%s"', (status, label) => {
render(<StatusBadge status={status} />);
expect(screen.getByText(label)).toBeInTheDocument();
});
});
+11 -6
View File
@@ -1,17 +1,22 @@
import { TicketStatus } from '../types' import { TicketStatus } from '../types';
const config: Record<TicketStatus, { label: string; className: string }> = { const config: Record<TicketStatus, { label: string; className: string }> = {
OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' }, OPEN: { label: 'Open', className: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
IN_PROGRESS: { label: 'In Progress', className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' }, IN_PROGRESS: {
label: 'In Progress',
className: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
},
RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' }, RESOLVED: { label: 'Resolved', className: 'bg-green-500/20 text-green-400 border-green-500/30' },
CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' }, CLOSED: { label: 'Closed', className: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
} };
export default function StatusBadge({ status }: { status: TicketStatus }) { export default function StatusBadge({ status }: { status: TicketStatus }) {
const { label, className } = config[status] const { label, className } = config[status];
return ( return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}> <span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${className}`}
>
{label} {label}
</span> </span>
) );
} }
+122
View File
@@ -0,0 +1,122 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+32
View File
@@ -0,0 +1,32 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+113
View File
@@ -0,0 +1,113 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-border px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[320px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm text-muted-foreground"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};
+98
View File
@@ -0,0 +1,98 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+176
View File
@@ -0,0 +1,176 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+28
View File
@@ -0,0 +1,28 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+147
View File
@@ -0,0 +1,147 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
}
export { Skeleton };
+24
View File
@@ -0,0 +1,24 @@
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
+52
View File
@@ -0,0 +1,52 @@
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea };
+27
View File
@@ -0,0 +1,27 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+30 -32
View File
@@ -1,59 +1,57 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import api from '../api/client' import api from '../api/client';
import { User } from '../types' import { User } from '../types';
interface AuthContextType { interface AuthContextType {
user: User | null user: User | null;
loading: boolean loading: boolean;
login: (username: string, password: string) => Promise<void> login: (username: string, password: string) => Promise<void>;
logout: () => void logout: () => void;
} }
const AuthContext = createContext<AuthContextType>(null!) const AuthContext = createContext<AuthContextType>(null!);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token') const token = localStorage.getItem('token');
if (token) { if (token) {
api.defaults.headers.common['Authorization'] = `Bearer ${token}` api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
api api
.get<User>('/auth/me') .get<User>('/auth/me')
.then((res) => setUser(res.data)) .then((res) => setUser(res.data))
.catch(() => { .catch(() => {
localStorage.removeItem('token') localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'] delete api.defaults.headers.common['Authorization'];
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false));
} else { } else {
setLoading(false) setLoading(false);
} }
}, []) }, []);
const login = async (username: string, password: string) => { const login = async (username: string, password: string) => {
const res = await api.post<{ token: string; user: User }>('/auth/login', { const res = await api.post<{ token: string; user: User }>('/auth/login', {
username, username,
password, password,
}) });
const { token, user } = res.data const { token, user } = res.data;
localStorage.setItem('token', token) localStorage.setItem('token', token);
api.defaults.headers.common['Authorization'] = `Bearer ${token}` api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
setUser(user) setUser(user);
} };
const logout = () => { const logout = () => {
localStorage.removeItem('token') localStorage.removeItem('token');
delete api.defaults.headers.common['Authorization'] delete api.defaults.headers.common['Authorization'];
setUser(null) setUser(null);
} };
return ( return (
<AuthContext.Provider value={{ user, loading, login, logout }}> <AuthContext.Provider value={{ user, loading, login, logout }}>{children}</AuthContext.Provider>
{children} );
</AuthContext.Provider>
)
} }
export const useAuth = () => useContext(AuthContext) export const useAuth = () => useContext(AuthContext);
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextType>(null!);
function initial(): Theme {
// v1.0 ships dark-only; light-mode pages migrate in a future pass.
// Theme context is kept so the toggle can be re-enabled later without wiring.
return 'dark';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(initial);
useEffect(() => {
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider
value={{ theme, toggle: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }}
>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
export function isTextField(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (el.isContentEditable) return true;
return false;
}
type Handler = (e: KeyboardEvent) => void;
/**
* Fires when the given key is pressed outside any text field.
* Pass modifiers as part of the key spec, e.g. `mod+k` for Ctrl/Meta+K.
* Single-char keys ignore shift unless explicitly `shift+x`.
*/
export function useShortcut(spec: string | string[], handler: Handler, deps: unknown[] = []) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
const specs = (Array.isArray(spec) ? spec : [spec]).map(parseSpec);
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) {
// Allow mod+key even inside text fields (e.g. ⌘K)
if (!specs.some((s) => s.mod && matches(s, e))) return;
}
for (const s of specs) {
if (matches(s, e)) {
handlerRef.current(e);
return;
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
interface ParsedSpec {
key: string;
mod: boolean;
shift: boolean;
}
function parseSpec(s: string): ParsedSpec {
const parts = s.toLowerCase().split('+');
const key = parts[parts.length - 1];
return {
key,
mod: parts.includes('mod'),
shift: parts.includes('shift'),
};
}
function matches(s: ParsedSpec, e: KeyboardEvent): boolean {
if (e.key.toLowerCase() !== s.key) return false;
if (s.mod !== (e.metaKey || e.ctrlKey)) return false;
if (s.shift && !e.shiftKey) return false;
return true;
}
/**
* Tracks a two-key "g" prefix and fires the matching handler when the second
* key is pressed within the timeout window. Any other key cancels.
*/
export function useLeaderShortcut(
leader: string,
mapping: Record<string, () => void>,
timeoutMs = 1000,
) {
const state = useRef<{ armed: boolean; timer: number | null }>({ armed: false, timer: null });
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isTextField(e.target)) return;
if (e.metaKey || e.ctrlKey || e.altKey) {
state.current.armed = false;
return;
}
const key = e.key.toLowerCase();
if (!state.current.armed) {
if (key === leader) {
state.current.armed = true;
if (state.current.timer) window.clearTimeout(state.current.timer);
state.current.timer = window.setTimeout(() => {
state.current.armed = false;
}, timeoutMs);
e.preventDefault();
}
return;
}
// armed: expect second key
state.current.armed = false;
if (state.current.timer) {
window.clearTimeout(state.current.timer);
state.current.timer = null;
}
const fn = mapping[key];
if (fn) {
e.preventDefault();
fn();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leader, timeoutMs, JSON.stringify(Object.keys(mapping))]);
}
+119 -19
View File
@@ -2,29 +2,129 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 263 70% 50.4%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 263 70% 50.4%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 263 70% 50.4%;
--primary-foreground: 0 0% 98%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 263 70% 50.4%;
}
}
/* Native select dark option styling */ /* Native select dark option styling */
select option { select option {
background-color: #1f2937; background-color: #27272a;
color: #f3f4f6; color: #f3f4f6;
} }
/* Markdown prose styles (dark) */ /* Markdown prose styles (dark) */
.prose p { @apply mb-3 last:mb-0 leading-relaxed; } .prose p {
.prose h1 { @apply text-xl font-bold mb-3 mt-5; } @apply mb-3 last:mb-0 leading-relaxed;
.prose h2 { @apply text-lg font-semibold mb-2 mt-4; } }
.prose h3 { @apply text-base font-semibold mb-2 mt-3; } .prose h1 {
.prose ul { @apply list-disc pl-5 mb-3 space-y-1; } @apply text-xl font-bold mb-3 mt-5;
.prose ol { @apply list-decimal pl-5 mb-3 space-y-1; } }
.prose h2 {
@apply text-lg font-semibold mb-2 mt-4;
}
.prose h3 {
@apply text-base font-semibold mb-2 mt-3;
}
.prose ul {
@apply list-disc pl-5 mb-3 space-y-1;
}
.prose ol {
@apply list-decimal pl-5 mb-3 space-y-1;
}
.prose li > ul, .prose li > ul,
.prose li > ol { @apply mt-1 mb-0; } .prose li > ol {
.prose a { @apply text-blue-400 underline hover:text-blue-300; } @apply mt-1 mb-0;
.prose strong { @apply font-semibold; } }
.prose em { @apply italic; } .prose a {
.prose blockquote { @apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic; } @apply text-indigo-400 underline hover:text-indigo-300;
.prose code { @apply bg-gray-800 text-gray-300 px-1.5 py-0.5 rounded text-xs font-mono; } }
.prose pre { @apply bg-gray-950 text-gray-300 p-4 rounded-lg my-3 overflow-x-auto text-sm; } .prose strong {
.prose pre code { @apply bg-transparent text-gray-300 p-0; } @apply font-semibold;
.prose hr { @apply border-gray-700 my-4; } }
.prose table { @apply w-full border-collapse text-sm my-3; } .prose em {
.prose th { @apply bg-gray-800 border border-gray-700 px-3 py-2 text-left font-semibold text-gray-300; } @apply italic;
.prose td { @apply border border-gray-700 px-3 py-2 text-gray-400; } }
.prose blockquote {
@apply border-l-4 border-gray-600 pl-4 text-gray-400 my-3 italic;
}
.prose code {
@apply bg-gray-800 text-gray-300 px-1.5 py-0.5 rounded text-xs font-mono;
}
.prose pre {
@apply bg-gray-950 text-gray-300 p-4 rounded-lg my-3 overflow-x-auto text-sm;
}
.prose pre code {
@apply bg-transparent text-gray-300 p-0;
}
.prose hr {
@apply border-gray-700 my-4;
}
.prose table {
@apply w-full border-collapse text-sm my-3;
}
.prose th {
@apply bg-gray-800 border border-gray-700 px-3 py-2 text-left font-semibold text-gray-300;
}
.prose td {
@apply border border-gray-700 px-3 py-2 text-gray-400;
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { injectMentionLinks } from './mentions';
const users = [
{ id: 'u1', username: 'alice', displayName: 'Alice' },
{ id: 'u2', username: 'bob-smith', displayName: 'Bob Smith' },
];
describe('injectMentionLinks', () => {
it('rewrites known mentions to markdown links with assignee filter', () => {
const out = injectMentionLinks('hey @alice check this', users);
expect(out).toBe('hey [@alice](/tickets?assigneeId=u1) check this');
});
it('leaves unknown mentions alone', () => {
expect(injectMentionLinks('ping @ghost', users)).toBe('ping @ghost');
});
it('ignores email-like strings', () => {
expect(injectMentionLinks('mail alice@example.com', users)).toBe(
'mail alice@example.com',
);
});
it('matches at start of string', () => {
expect(injectMentionLinks('@alice hi', users)).toBe(
'[@alice](/tickets?assigneeId=u1) hi',
);
});
it('handles hyphenated usernames', () => {
expect(injectMentionLinks('cc @bob-smith', users)).toBe(
'cc [@bob-smith](/tickets?assigneeId=u2)',
);
});
it('skips processing when no @ in input', () => {
expect(injectMentionLinks('no mentions here', users)).toBe('no mentions here');
});
});
+17
View File
@@ -0,0 +1,17 @@
import type { User } from '../types';
type UserLite = Pick<User, 'id' | 'username' | 'displayName'>;
/**
* Rewrites `@username` tokens (outside word chars) into markdown links pointing
* to that user's ticket queue. Unknown usernames are left as plain text.
*/
export function injectMentionLinks(body: string, users: UserLite[]): string {
if (!body.includes('@') || users.length === 0) return body;
const byName = new Map(users.map((u) => [u.username.toLowerCase(), u]));
return body.replace(/(^|[^\w-])@([a-zA-Z0-9_-]+)/g, (_m, pre: string, name: string) => {
const user = byName.get(name.toLowerCase());
if (!user) return `${pre}@${name}`;
return `${pre}[@${user.username}](/tickets?assigneeId=${user.id})`;
});
}
+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' },
};
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+23 -6
View File
@@ -1,10 +1,27 @@
import { StrictMode } from 'react' import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client';
import './index.css' import { QueryClientProvider } from '@tanstack/react-query';
import App from './App' import { queryClient } from './api/queryClient';
import { ThemeProvider } from './contexts/ThemeContext';
import { Toaster } from '@/components/ui/sonner';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<App /> <App />
</StrictMode> <Toaster />
) </QueryClientProvider>
</ThemeProvider>
</StrictMode>,
);
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Installability is a progressive enhancement; ignore failures
});
});
}
+168 -305
View File
@@ -1,328 +1,191 @@
import { useState, useEffect, useCallback } from 'react' import { useMemo } from 'react';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { Search, ChevronRight, X } from 'lucide-react' import { AlertTriangle, Clock, TrendingUp, Users } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns' import Layout from '../components/Layout';
import api from '../api/client' import { useAnalytics, useUsers } from '../api/queries';
import Layout from '../components/Layout'
import SeverityBadge from '../components/SeverityBadge'
import StatusBadge from '../components/StatusBadge'
import Avatar from '../components/Avatar'
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types'
const STATUSES: { value: TicketStatus | ''; label: string }[] = [ const SEV_NAMES: Record<number, string> = {
{ value: '', label: 'All Statuses' }, 1: 'SEV 1 — Critical',
{ value: 'OPEN', label: 'Open' }, 2: 'SEV 2 — High',
{ value: 'IN_PROGRESS', label: 'In Progress' }, 3: 'SEV 3 — Medium',
{ value: 'RESOLVED', label: 'Resolved' }, 4: 'SEV 4 — Low',
{ value: 'CLOSED', label: 'Closed' }, 5: 'SEV 5 — Minimal',
] };
const selectClass = const SEV_COLORS: Record<number, string> = {
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' 1: 'bg-red-500',
2: 'bg-orange-400',
3: 'bg-yellow-400',
4: 'bg-blue-400',
5: 'bg-gray-500',
};
// Queue label built from whatever CTI level is selected function fmtHours(hours: number | null) {
function queueLabel( if (hours == null) return '—';
category: Category | null, if (hours < 1) return `${Math.round(hours * 60)} min`;
type: CTIType | null, if (hours < 48) return `${hours.toFixed(1)} h`;
item: Item | null, return `${(hours / 24).toFixed(1)} d`;
): string {
if (item && type && category) return `${category.name} ${type.name} ${item.name}`
if (type && category) return `${category.name} ${type.name}`
if (category) return category.name
return ''
} }
export default function Dashboard() { export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([]) const { data: a } = useAnalytics(30);
const [loading, setLoading] = useState(true) const { data: users = [] } = useUsers();
const [search, setSearch] = useState('')
const [status, setStatus] = useState<TicketStatus | ''>('')
const [severity, setSeverity] = useState('')
// CTI queue filter state const userById = useMemo(
const [categories, setCategories] = useState<Category[]>([]) () => new Map(users.map((u) => [u.id, u.displayName])),
const [types, setTypes] = useState<CTIType[]>([]) [users],
const [items, setItems] = useState<Item[]>([]) );
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
const [showQueueFilter, setShowQueueFilter] = useState(false)
useEffect(() => { const totalOpen =
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data)) a?.openBySeverity.reduce((sum, row) => sum + row.count, 0) ?? 0;
}, []) const maxBucket = Math.max(
...Object.values(a?.ageBuckets ?? { d1: 0, d7: 0, d14: 0, older: 0 }),
const handleCategorySelect = (cat: Category) => { 1,
setSelectedCategory(cat) );
setSelectedType(null) const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1]));
setSelectedItem(null)
setTypes([])
setItems([])
api.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } }).then((r) => setTypes(r.data))
}
const handleTypeSelect = (type: CTIType) => {
setSelectedType(type)
setSelectedItem(null)
setItems([])
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data))
}
const handleItemSelect = (item: Item) => {
setSelectedItem(item)
setShowQueueFilter(false)
}
const clearQueue = () => {
setSelectedCategory(null)
setSelectedType(null)
setSelectedItem(null)
setTypes([])
setItems([])
}
// Derive the most specific filter param
const queueParams: Record<string, string> = {}
if (selectedItem) queueParams.itemId = selectedItem.id
else if (selectedType) queueParams.typeId = selectedType.id
else if (selectedCategory) queueParams.categoryId = selectedCategory.id
const fetchTickets = useCallback(() => {
setLoading(true)
const params: Record<string, string> = { ...queueParams }
if (status) params.status = status
if (severity) params.severity = severity
if (search) params.search = search
api
.get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, severity, search, selectedCategory, selectedType, selectedItem])
useEffect(() => {
const t = setTimeout(fetchTickets, 300)
return () => clearTimeout(t)
}, [fetchTickets])
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem)
return ( return (
<Layout title="All Tickets"> <Layout wide title="Dashboard" subheader={<p className="text-xs text-muted-foreground">Last 30 days</p>}>
{/* Filters */} {!a ? (
<div className="flex gap-3 mb-5 flex-wrap items-start"> <p className="py-16 text-center text-sm text-muted-foreground">Loading analytics</p>
<div className="relative flex-1 min-w-48 max-w-sm"> ) : (
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<input <Card
type="text" icon={<AlertTriangle size={14} />}
placeholder="Search tickets..." label="Open tickets"
value={search} value={totalOpen.toString()}
onChange={(e) => setSearch(e.target.value)} />
className="pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" <Card
icon={<Clock size={14} />}
label="Aging >7d"
value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()}
/>
<Card
icon={<TrendingUp size={14} />}
label="Median resolution"
value={fmtHours(a.medianResolutionHours)}
/>
<Card
icon={<Users size={14} />}
label="Assignees loaded"
value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()}
/> />
</div>
<select {/* Open by severity */}
value={status} <section className="md:col-span-2 rounded-md border border-border p-4">
onChange={(e) => setStatus(e.target.value as TicketStatus | '')} <h2 className="text-sm font-semibold mb-3">Open by severity</h2>
className={selectClass} <div className="space-y-2">
> {[1, 2, 3, 4, 5].map((sev) => {
{STATUSES.map((s) => ( const row = a.openBySeverity.find((r) => r.severity === sev);
<option key={s.value} value={s.value}> const count = row?.count ?? 0;
{s.label} const pct = maxSeverity > 0 ? (count / maxSeverity) * 100 : 0;
</option> return (
))} <div key={sev} className="flex items-center gap-3 text-sm">
</select> <span className="w-32 text-xs text-muted-foreground">
{SEV_NAMES[sev]}
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className={selectClass}
>
<option value="">All Severities</option>
{[1, 2, 3, 4, 5].map((s) => (
<option key={s} value={s}>
SEV {s}
</option>
))}
</select>
{/* Queue picker */}
<div className="relative">
<button
onClick={() => setShowQueueFilter((v) => !v)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border transition-colors ${
activeQueue
? 'bg-blue-600/20 border-blue-500/40 text-blue-400'
: 'bg-gray-800 border-gray-700 text-gray-300 hover:border-gray-600'
}`}
>
{activeQueue ? (
<>
<span className="max-w-48 truncate">{activeQueue}</span>
<span
onClick={(e) => { e.stopPropagation(); clearQueue() }}
className="text-blue-400 hover:text-white transition-colors cursor-pointer"
>
<X size={13} />
</span> </span>
</> <div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
) : (
'All Queues'
)}
</button>
{showQueueFilter && (
<div className="absolute z-20 top-full mt-1 left-0 bg-gray-900 border border-gray-700 rounded-xl shadow-2xl overflow-hidden flex"
style={{ minWidth: '520px' }}
>
{/* Categories */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Category
</p>
<div className="overflow-auto max-h-64">
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => handleCategorySelect(cat)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedCategory?.id === cat.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{cat.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))}
</div>
</div>
{/* Types */}
<div className="w-44 border-r border-gray-800">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Type
</p>
<div className="overflow-auto max-h-64">
{!selectedCategory ? (
<p className="text-xs text-gray-600 px-3 py-4">Select category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No types</p>
) : (
types.map((type) => (
<button
key={type.id}
onClick={() => handleTypeSelect(type)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors ${
selectedType?.id === type.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{type.name}
<ChevronRight size={12} className="text-gray-600" />
</button>
))
)}
</div>
</div>
{/* Items */}
<div className="w-44">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-3 py-2 border-b border-gray-800">
Item
</p>
<div className="overflow-auto max-h-64">
{!selectedType ? (
<p className="text-xs text-gray-600 px-3 py-4">Select type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-600 px-3 py-4">No items</p>
) : (
items.map((item) => (
<button
key={item.id}
onClick={() => handleItemSelect(item)}
className={`w-full px-3 py-2 text-sm text-left transition-colors ${
selectedItem?.id === item.id
? 'bg-blue-600/20 text-blue-400'
: 'text-gray-300 hover:bg-gray-800'
}`}
>
{item.name}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Ticket list */}
{loading ? (
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
) : (
<div className="space-y-1.5">
{tickets.map((ticket) => (
<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 hover:bg-gray-900/80 transition-all group"
>
<div <div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${ className={`h-full ${SEV_COLORS[sev]}`}
ticket.severity === 1 style={{ width: `${pct}%` }}
? 'bg-red-500'
: ticket.severity === 2
? 'bg-orange-400'
: ticket.severity === 3
? 'bg-yellow-400'
: ticket.severity === 4
? 'bg-blue-400'
: 'bg-gray-600'
}`}
/> />
</div>
<div className="flex-1 min-w-0"> <span className="w-10 text-right text-xs font-mono tabular-nums">
<div className="flex items-center gap-2 mb-0.5 flex-wrap"> {count}
<span className="text-xs font-mono font-medium text-gray-600">
{ticket.displayId}
</span>
<SeverityBadge severity={ticket.severity} />
<StatusBadge status={ticket.status} />
<span className="text-xs text-gray-600">
{ticket.category.name} {ticket.type.name} {ticket.item.name}
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-200 truncate group-hover:text-blue-400"> );
{ticket.title} })}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
{ticket.assignee ? (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Avatar name={ticket.assignee.displayName} size="sm" />
<span>{ticket.assignee.displayName}</span>
</div>
) : (
<span className="text-xs text-gray-600">Unassigned</span>
)}
<span className="text-xs text-gray-600">
{ticket._count?.comments ?? 0} comments
</span>
<span className="text-xs text-gray-600">
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div> </div>
<div className="mt-3">
<Link
to="/tickets?status=OPEN"
className="text-xs text-primary hover:underline"
>
Go to open tickets
</Link> </Link>
))} </div>
</section>
{/* Age buckets */}
<section className="md:col-span-2 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Age of open tickets</h2>
<div className="space-y-2">
{[
{ key: 'd1', label: '≤ 1 day' },
{ key: 'd7', label: '≤ 7 days' },
{ key: 'd14', label: '≤ 14 days' },
{ key: 'older', label: '> 14 days' },
].map(({ key, label }) => {
const count = a.ageBuckets[key as keyof typeof a.ageBuckets] ?? 0;
const pct = (count / maxBucket) * 100;
return (
<div key={key} className="flex items-center gap-3 text-sm">
<span className="w-24 text-xs text-muted-foreground">{label}</span>
<div className="flex-1 h-5 rounded-sm bg-muted overflow-hidden">
<div
className={`h-full ${key === 'older' ? 'bg-red-500' : 'bg-primary'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-10 text-right text-xs font-mono tabular-nums">
{count}
</span>
</div>
);
})}
</div>
</section>
{/* Queue by assignee */}
<section className="md:col-span-2 lg:col-span-4 rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Queue load by assignee</h2>
{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 xl:grid-cols-4 2xl:grid-cols-6">
{a.queueByAssignee
.slice()
.sort((x, y) => y.count - x.count)
.map((row) => {
const name = row.assigneeId
? userById.get(row.assigneeId) ?? 'Unknown'
: 'Unassigned';
return (
<div
key={row.assigneeId ?? 'none'}
className="flex items-center justify-between px-3 py-2 rounded-md bg-muted/40"
>
<span className="text-sm truncate">{name}</span>
<span className="text-xs font-mono tabular-nums">{row.count}</span>
</div>
);
})}
</div>
)}
</section>
</div> </div>
)} )}
</Layout> </Layout>
) );
}
function Card({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-md border border-border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
{icon}
<span>{label}</span>
</div>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
</div>
);
} }
+43 -45
View File
@@ -1,28 +1,36 @@
import { useState } from 'react' import { useState } from 'react';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext' import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginInput } from '../../../shared/schemas/auth';
import { useAuth } from '../contexts/AuthContext';
export default function Login() { export default function Login() {
const { login } = useAuth() const { login } = useAuth();
const navigate = useNavigate() const navigate = useNavigate();
const [username, setUsername] = useState('') const [error, setError] = useState('');
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => { const {
e.preventDefault() register,
setError('') handleSubmit,
setLoading(true) formState: { errors, isSubmitting },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { username: '', password: '' },
});
const onSubmit = async (data: LoginInput) => {
setError('');
try { try {
await login(username, password) await login(data.username, data.password);
navigate('/') navigate('/');
} catch { } catch {
setError('Invalid username or password') setError('Invalid username or password');
} finally {
setLoading(false)
}
} }
};
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-indigo-500 focus:border-transparent';
return ( return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center px-4"> <div className="min-h-screen bg-gray-950 flex items-center justify-center px-4">
@@ -33,8 +41,9 @@ export default function Login() {
</div> </div>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit(onSubmit)}
className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl p-8 space-y-4" className="bg-gray-900 border border-gray-800 rounded-xl shadow-2xl p-8 space-y-4"
noValidate
> >
{error && ( {error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg"> <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
@@ -43,41 +52,30 @@ export default function Login() {
)} )}
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-300 mb-1">Username</label>
Username <input type="text" autoFocus className={inputClass} {...register('username')} />
</label> {errors.username && (
<input <p className="mt-1 text-xs text-red-400">{errors.username.message}</p>
type="text" )}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="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"
/>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-1"> <label className="block text-sm font-medium text-gray-300 mb-1">Password</label>
Password <input type="password" className={inputClass} {...register('password')} />
</label> {errors.password && (
<input <p className="mt-1 text-xs text-red-400">{errors.password.message}</p>
type="password" )}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="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"
/>
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} 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"
> >
{loading ? 'Signing in...' : 'Sign in'} {isSubmitting ? 'Signing in...' : 'Sign in'}
</button> </button>
</form> </form>
</div> </div>
</div> </div>
) );
} }
+28 -40
View File
@@ -1,32 +1,30 @@
import { useState, useEffect } from 'react' import { useMemo } from 'react';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns';
import api from '../api/client' import Layout from '../components/Layout';
import Layout from '../components/Layout' import SeverityBadge from '../components/SeverityBadge';
import SeverityBadge from '../components/SeverityBadge' import StatusBadge from '../components/StatusBadge';
import StatusBadge from '../components/StatusBadge' import { useTickets } from '../api/queries';
import { Ticket } from '../types' import { useAuth } from '../contexts/AuthContext';
import { useAuth } from '../contexts/AuthContext' import { SEVERITY_BG } from '../lib/severityColors';
export default function MyTickets() { export default function MyTickets() {
const { user } = useAuth() const { user } = useAuth();
const [tickets, setTickets] = useState<Ticket[]>([]) const openQ = useTickets(user ? { assigneeId: user.id, status: 'OPEN' } : {});
const [loading, setLoading] = useState(true) const inProgressQ = useTickets(user ? { assigneeId: user.id, status: 'IN_PROGRESS' } : {});
useEffect(() => { const loading = openQ.isLoading || inProgressQ.isLoading;
if (!user) return
// Only show active tickets — OPEN and IN_PROGRESS const tickets = useMemo(() => {
Promise.all([ if (!user) return [];
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }), const combined = [...(openQ.data ?? []), ...(inProgressQ.data ?? [])];
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }), combined.sort(
]) (a, b) =>
.then(([openRes, inProgressRes]) => { a.severity - b.severity ||
const combined = [...openRes.data, ...inProgressRes.data] new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
combined.sort((a, b) => a.severity - b.severity || new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) );
setTickets(combined) return combined;
}) }, [user, openQ.data, inProgressQ.data]);
.finally(() => setLoading(false))
}, [user])
return ( return (
<Layout title="My Tickets"> <Layout title="My Tickets">
@@ -42,20 +40,10 @@ export default function MyTickets() {
<Link <Link
key={ticket.id} key={ticket.id}
to={`/${ticket.displayId}`} 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 <div
className={`w-1 self-stretch rounded-full flex-shrink-0 ${ className={`w-1 self-stretch rounded-full flex-shrink-0 ${SEVERITY_BG[ticket.severity] ?? 'bg-gray-600'}`}
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'
}`}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -69,7 +57,7 @@ export default function MyTickets() {
{ticket.category.name} {ticket.type.name} {ticket.item.name} {ticket.category.name} {ticket.type.name} {ticket.item.name}
</span> </span>
</div> </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} {ticket.title}
</p> </p>
</div> </div>
@@ -87,5 +75,5 @@ export default function MyTickets() {
</div> </div>
)} )}
</Layout> </Layout>
) );
} }
+82 -73
View File
@@ -1,74 +1,62 @@
import { useState, useEffect } from 'react' import { useState } from 'react';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import api from '../api/client' import { useForm, Controller } from 'react-hook-form';
import Modal from '../components/Modal' import { zodResolver } from '@hookform/resolvers/zod';
import CTISelect from '../components/CTISelect' import { createTicketSchema, type CreateTicketInput } from '../../../shared/schemas/ticket';
import { User } from '../types' import Modal from '../components/Modal';
import CTISelect from '../components/CTISelect';
import { useUsers, useCreateTicket } from '../api/queries';
interface NewTicketModalProps { interface NewTicketModalProps {
onClose: () => void onClose: () => void;
} }
export default function NewTicketModal({ onClose }: NewTicketModalProps) { export default function NewTicketModal({ onClose }: NewTicketModalProps) {
const navigate = useNavigate() const navigate = useNavigate();
const [users, setUsers] = useState<User[]>([]) const { data: users = [] } = useUsers();
const [error, setError] = useState('') const createTicket = useCreateTicket();
const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('');
const [form, setForm] = useState({ const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<CreateTicketInput>({
resolver: zodResolver(createTicketSchema),
defaultValues: {
title: '', title: '',
overview: '', overview: '',
severity: 3, severity: 3,
assigneeId: '',
categoryId: '', categoryId: '',
typeId: '', typeId: '',
itemId: '', itemId: '',
}) assigneeId: undefined,
},
});
useEffect(() => { const onSubmit = async (data: CreateTicketInput) => {
api.get<User[]>('/users').then((r) => setUsers(r.data)) setError('');
}, [])
const handleCTI = (cti: { categoryId: string; typeId: string; itemId: string }) => {
setForm((f) => ({ ...f, ...cti }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.categoryId || !form.typeId || !form.itemId) {
setError('Please select a Category, Type, and Item')
return
}
setError('')
setSubmitting(true)
try { try {
const payload: Record<string, unknown> = { const { assigneeId, ...rest } = data;
title: form.title, const created = await createTicket.mutateAsync(
overview: form.overview, assigneeId ? { ...rest, assigneeId } : rest,
severity: form.severity, );
categoryId: form.categoryId, onClose();
typeId: form.typeId, navigate(`/${created.displayId}`);
itemId: form.itemId,
}
if (form.assigneeId) payload.assigneeId = form.assigneeId
const res = await api.post('/tickets', payload)
onClose()
navigate(`/${res.data.displayId}`)
} catch { } catch {
setError('Failed to create ticket') setError('Failed to create ticket');
} finally {
setSubmitting(false)
}
} }
};
const inputClass = 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 labelClass = 'block text-sm font-medium text-gray-300 mb-1';
const errorClass = 'mt-1 text-xs text-red-400';
return ( return (
<Modal title="New Ticket" onClose={onClose} size="lg"> <Modal title="New Ticket" onClose={onClose} size="lg">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{error && ( {error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg"> <div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-lg">
{error} {error}
@@ -79,34 +67,31 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<label className={labelClass}>Title</label> <label className={labelClass}>Title</label>
<input <input
type="text" type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
required
className={inputClass} className={inputClass}
placeholder="Brief description of the issue" placeholder="Brief description of the issue"
autoFocus autoFocus
{...register('title')}
/> />
{errors.title && <p className={errorClass}>{errors.title.message}</p>}
</div> </div>
<div> <div>
<label className={labelClass}>Overview</label> <label className={labelClass}>Overview</label>
<textarea <textarea
value={form.overview}
onChange={(e) => setForm((f) => ({ ...f, overview: e.target.value }))}
required
rows={4} rows={4}
className={inputClass} className={inputClass}
placeholder="Detailed description... Markdown supported" placeholder="Detailed description... Markdown supported"
{...register('overview')}
/> />
{errors.overview && <p className={errorClass}>{errors.overview.message}</p>}
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className={labelClass}>Severity</label> <label className={labelClass}>Severity</label>
<select <select
value={form.severity}
onChange={(e) => setForm((f) => ({ ...f, severity: Number(e.target.value) }))}
className={inputClass} className={inputClass}
{...register('severity', { valueAsNumber: true })}
> >
<option value={1}>SEV 1 Critical</option> <option value={1}>SEV 1 Critical</option>
<option value={2}>SEV 2 High</option> <option value={2}>SEV 2 High</option>
@@ -114,19 +99,14 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<option value={4}>SEV 4 Low</option> <option value={4}>SEV 4 Low</option>
<option value={5}>SEV 5 Minimal</option> <option value={5}>SEV 5 Minimal</option>
</select> </select>
{errors.severity && <p className={errorClass}>{errors.severity.message}</p>}
</div> </div>
<div> <div>
<label className={labelClass}>Assignee</label> <label className={labelClass}>Assignee</label>
<select <select className={inputClass} {...register('assigneeId')}>
value={form.assigneeId}
onChange={(e) => setForm((f) => ({ ...f, assigneeId: e.target.value }))}
className={inputClass}
>
<option value="">Unassigned</option> <option value="">Unassigned</option>
{users {users.map((u) => (
.filter((u) => u.role !== 'SERVICE')
.map((u) => (
<option key={u.id} value={u.id}> <option key={u.id} value={u.id}>
{u.displayName} {u.displayName}
</option> </option>
@@ -137,10 +117,39 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<div> <div>
<label className={labelClass}>Routing (CTI)</label> <label className={labelClass}>Routing (CTI)</label>
<Controller
control={control}
name="categoryId"
render={({ field: categoryField }) => (
<Controller
control={control}
name="typeId"
render={({ field: typeField }) => (
<Controller
control={control}
name="itemId"
render={({ field: itemField }) => (
<CTISelect <CTISelect
value={{ categoryId: form.categoryId, typeId: form.typeId, itemId: form.itemId }} value={{
onChange={handleCTI} categoryId: categoryField.value,
typeId: typeField.value,
itemId: itemField.value,
}}
onChange={(cti) => {
categoryField.onChange(cti.categoryId);
typeField.onChange(cti.typeId);
itemField.onChange(cti.itemId);
}}
/> />
)}
/>
)}
/>
)}
/>
{(errors.categoryId || errors.typeId || errors.itemId) && (
<p className={errorClass}>Please select a Category, Type, and Item</p>
)}
</div> </div>
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
@@ -153,13 +162,13 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitting} 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"
> >
{submitting ? 'Creating...' : 'Create Ticket'} {isSubmitting ? 'Creating...' : 'Create Ticket'}
</button> </button>
</div> </div>
</form> </form>
</Modal> </Modal>
) );
} }
+90
View File
@@ -0,0 +1,90 @@
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import Layout from '../components/Layout';
import { useNotifications, useMarkNotificationsRead } from '../api/queries';
import type { Notification } from '../../../shared/types';
const KIND_LABELS: Record<string, string> = {
ASSIGNED: 'assigned to you',
MENTION: 'mentioned you',
RESOLVED: 'was resolved',
TICKET_CREATED: 'was created',
STATUS_CHANGED: 'status changed',
COMMENT: 'new comment',
};
interface NotifData {
displayId?: string;
title?: string;
byName?: string;
}
function summary(n: Notification): { label: string; href: string } {
const data = (n.data ?? {}) as NotifData;
const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase();
const label = data.title ? `${data.title}${action}` : `Ticket ${action}`;
const href = data.displayId ? `/${data.displayId}` : '/notifications';
return { label, href };
}
export default function Notifications() {
const { data: notifications = [], isLoading } = useNotifications();
const markRead = useMarkNotificationsRead();
const unread = notifications.filter((n) => !n.readAt);
return (
<Layout
title="Notifications"
action={
unread.length > 0 ? (
<button
onClick={() => markRead.mutate({ all: true })}
className="text-sm px-3 py-1.5 rounded-md border border-input hover:bg-accent"
>
Mark all read
</button>
) : null
}
>
{isLoading ? (
<p className="py-16 text-center text-sm text-muted-foreground">Loading</p>
) : notifications.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
You&apos;re all caught up
</p>
) : (
<ul className="rounded-md border border-border divide-y divide-border overflow-hidden">
{notifications.map((n) => {
const { label, href } = summary(n);
const isUnread = !n.readAt;
return (
<li
key={n.id}
className={isUnread ? 'bg-primary/5' : ''}
>
<Link
to={href}
onClick={() => isUnread && markRead.mutate({ ids: [n.id] })}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors"
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
isUnread ? 'bg-primary' : 'bg-transparent'
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground line-clamp-1">{label}</p>
<p className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</Layout>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import Layout from '../components/Layout';
import { useAuth } from '../contexts/AuthContext';
import {
useNotificationPrefs,
useUpdateNotificationPrefs,
type NotificationPrefs,
} from '../api/queries';
const CHANNELS = [
{ key: 'assignment', label: 'Ticket assigned to me' },
{ key: 'mention', label: 'I am mentioned in a comment' },
{ key: 'resolved', label: 'A ticket I created is resolved' },
] as const;
export default function Settings() {
const { user } = useAuth();
const prefsQ = useNotificationPrefs();
const updatePrefs = useUpdateNotificationPrefs();
const [local, setLocal] = useState<NotificationPrefs | null>(null);
useEffect(() => {
if (prefsQ.data && !local) setLocal(prefsQ.data);
}, [prefsQ.data, local]);
const handleToggle = (channel: 'email' | 'inApp', key: keyof NotificationPrefs['email']) => {
setLocal((l) =>
l
? {
...l,
[channel]: { ...l[channel], [key]: !l[channel][key] },
}
: l,
);
};
const save = async () => {
if (!local) return;
try {
await updatePrefs.mutateAsync(local);
toast.success('Preferences saved');
} catch (e) {
toast.error((e as Error).message || 'Failed to save');
}
};
return (
<Layout title="Settings">
<div className="space-y-6">
{/* Profile */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Profile</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<Field label="Display name" value={user?.displayName ?? ''} />
<Field label="Username" value={user?.username ?? ''} mono />
<Field label="Email" value={user?.email ?? ''} />
<Field label="Role" value={user?.role ?? ''} />
</div>
</section>
{/* Notifications */}
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">Notification preferences</h2>
{local ? (
<div className="space-y-3">
<div className="grid grid-cols-[1fr_80px_80px] gap-2 text-xs uppercase text-muted-foreground pb-1 border-b border-border">
<span />
<span className="text-center">Email</span>
<span className="text-center">In app</span>
</div>
{CHANNELS.map(({ key, label }) => (
<div
key={key}
className="grid grid-cols-[1fr_80px_80px] gap-2 items-center text-sm"
>
<span>{label}</span>
<div className="text-center">
<input
type="checkbox"
checked={local.email[key]}
onChange={() => handleToggle('email', key)}
/>
</div>
<div className="text-center">
<input
type="checkbox"
checked={local.inApp[key]}
onChange={() => handleToggle('inApp', key)}
/>
</div>
</div>
))}
<div className="flex justify-end pt-2">
<button
onClick={save}
disabled={updatePrefs.isPending}
className="px-3 py-1.5 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
>
{updatePrefs.isPending ? 'Saving…' : 'Save preferences'}
</button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Loading</p>
)}
<p className="mt-3 text-xs text-muted-foreground">
Email notifications require the server&apos;s SMTP config. If SMTP is unset, only
in-app delivery happens regardless of these settings.
</p>
</section>
</div>
</Layout>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
<p className={`text-sm ${mono ? 'font-mono' : ''}`}>{value || '—'}</p>
</div>
);
}
-729
View File
@@ -1,729 +0,0 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
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 api from '../api/client'
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 { Ticket, TicketStatus, User, Comment, AuditLog } from '../types'
import { useAuth } from '../contexts/AuthContext'
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 [ticket, setTicket] = useState<Ticket | null>(null)
const [users, setUsers] = useState<User[]>([])
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<Tab>('overview')
const [editing, setEditing] = useState(false)
const [reroutingCTI, setReroutingCTI] = useState(false)
const [commentBody, setCommentBody] = useState('')
const [submittingComment, setSubmittingComment] = useState(false)
const [preview, setPreview] = useState(false)
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set())
const [editForm, setEditForm] = useState({ title: '', overview: '' })
const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' })
const [editingStatus, setEditingStatus] = useState(false)
const [editingSeverity, setEditingSeverity] = useState(false)
const [editingAssignee, setEditingAssignee] = useState(false)
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set())
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})
const toggleCommentDate = (id: string) =>
setExpandedCommentDates((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const isAdmin = authUser?.role === 'ADMIN'
useEffect(() => {
Promise.all([
api.get<Ticket>(`/tickets/${id}`),
api.get<User[]>('/users'),
]).then(([tRes, uRes]) => {
setTicket(tRes.data)
setUsers(uRes.data)
}).finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data))
}
}, [tab, ticket, id])
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return
const res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, payload)
setTicket(res.data)
return res.data
}
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 deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return
await api.delete(`/tickets/${ticket.displayId}`)
navigate('/')
}
const submitComment = async (e: React.FormEvent) => {
e.preventDefault()
if (!ticket || !commentBody.trim()) return
setSubmittingComment(true)
try {
const res = await api.post<Comment>(`/tickets/${ticket.displayId}/comments`, {
body: commentBody.trim(),
})
setTicket((t) => t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t)
setCommentBody('')
setPreview(false)
} finally {
setSubmittingComment(false)
}
}
const deleteComment = async (commentId: string) => {
if (!ticket) return
await api.delete(`/tickets/${ticket.displayId}/comments/${commentId}`)
setTicket((t) => t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t)
}
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 (loading) {
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')
// Status options: CLOSED only for admins
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(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-300 mb-4 transition-colors"
>
<ArrowLeft size={14} />
Back
</button>
<div className="flex gap-6 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={() => deleteComment(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]}>
{comment.body}
</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]}>{commentBody}</ReactMarkdown>
: <span className="text-gray-600 italic">Nothing to preview</span>
}
</div>
) : (
<textarea
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
placeholder="Leave a comment… Markdown supported"
rows={4}
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submitComment(e as unknown as React.FormEvent)
}
}}
/>
)}
<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={submittingComment || !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]}>
{log.detail!}
</ReactMarkdown>
</div>
) : (
<p className="text-sm text-gray-400">{log.detail}</p>
)}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Sidebar ── */}
<div className="w-64 flex-shrink-0 sticky 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 */}
<button
onClick={() => setEditingStatus(true)}
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>
{/* Severity */}
<button
onClick={() => setEditingSeverity(true)}
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>
{/* 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 */}
<button
onClick={() => setEditingAssignee(true)}
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>
{/* 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={deleteTicket}
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>
{editingStatus && (
<Modal title="Change Status" onClose={() => setEditingStatus(false)}>
<div className="space-y-2">
{statusOptions.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ status: s.value })
setEditingStatus(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.status === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<StatusBadge status={s.value} />
{ticket.status === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
{!isAdmin && (
<p className="text-xs text-gray-500 pt-1">Closing a ticket requires admin access</p>
)}
</div>
</Modal>
)}
{editingSeverity && (
<Modal title="Change Severity" onClose={() => setEditingSeverity(false)}>
<div className="space-y-2">
{SEVERITY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={async () => {
await patch({ severity: s.value })
setEditingSeverity(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.severity === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<SeverityBadge severity={s.value} />
<span className="text-sm text-gray-400">{s.label.split(' — ')[1]}</span>
{ticket.severity === s.value && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{editingAssignee && (
<Modal title="Change Assignee" onClose={() => setEditingAssignee(false)}>
<div className="space-y-2">
<button
onClick={async () => {
await patch({ assigneeId: null })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
!ticket.assigneeId
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<span className="text-sm text-gray-400">Unassigned</span>
{!ticket.assigneeId && <Check size={14} className="ml-auto text-blue-400" />}
</button>
{agentUsers.map((u) => (
<button
key={u.id}
onClick={async () => {
await patch({ assigneeId: u.id })
setEditingAssignee(false)
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${
ticket.assigneeId === u.id
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:bg-gray-800'
}`}
>
<Avatar name={u.displayName} size="sm" />
<span className="text-sm text-gray-300">{u.displayName}</span>
{ticket.assigneeId === u.id && <Check size={14} className="ml-auto text-blue-400" />}
</button>
))}
</div>
</Modal>
)}
{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>
)
}
+128 -107
View File
@@ -1,147 +1,150 @@
import { useState, useEffect } from 'react' import { useState } from 'react';
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react' import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react';
import api from '../../api/client' import Layout from '../../components/Layout';
import Layout from '../../components/Layout' import Modal from '../../components/Modal';
import Modal from '../../components/Modal' import { Category, CTIType } from '../../types';
import { Category, CTIType, Item } from '../../types' import {
useCategories,
useTypes,
useItems,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
useCreateType,
useUpdateType,
useDeleteType,
useCreateItem,
useUpdateItem,
useDeleteItem,
} from '../../api/queries';
type PanelItem = { id: string; name: string } type PanelItem = { id: string; name: string };
type Panel = 'category' | 'type' | 'item';
interface NameModalState { interface NameModalState {
open: boolean open: boolean;
mode: 'add' | 'edit' mode: 'add' | 'edit';
panel: 'category' | 'type' | 'item' panel: Panel;
item?: PanelItem item?: PanelItem;
} }
export default function AdminCTI() { export default function AdminCTI() {
const [categories, setCategories] = useState<Category[]>([]) const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [types, setTypes] = useState<CTIType[]>([]) const [selectedType, setSelectedType] = useState<CTIType | null>(null);
const [items, setItems] = useState<Item[]>([])
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null) const { data: categories = [] } = useCategories();
const [selectedType, setSelectedType] = useState<CTIType | null>(null) const { data: types = [] } = useTypes(selectedCategory?.id);
const { data: items = [] } = useItems(selectedType?.id);
const [nameModal, setNameModal] = useState<NameModalState>({ open: false, mode: 'add', panel: 'category' }) const createCategory = useCreateCategory();
const [nameValue, setNameValue] = useState('') const updateCategory = useUpdateCategory();
const [submitting, setSubmitting] = useState(false) const deleteCategory = useDeleteCategory();
const createType = useCreateType();
const updateType = useUpdateType();
const deleteType = useDeleteType();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const deleteItem = useDeleteItem();
const fetchCategories = () => const [nameModal, setNameModal] = useState<NameModalState>({
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data)) open: false,
mode: 'add',
panel: 'category',
});
const [nameValue, setNameValue] = useState('');
const fetchTypes = (categoryId: string) => const submitting =
api createCategory.isPending ||
.get<CTIType[]>('/cti/types', { params: { categoryId } }) updateCategory.isPending ||
.then((r) => setTypes(r.data)) createType.isPending ||
updateType.isPending ||
const fetchItems = (typeId: string) => createItem.isPending ||
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data)) updateItem.isPending;
useEffect(() => { fetchCategories() }, [])
const selectCategory = (cat: Category) => { const selectCategory = (cat: Category) => {
setSelectedCategory(cat) setSelectedCategory(cat);
setSelectedType(null) setSelectedType(null);
setItems([]) };
fetchTypes(cat.id)
}
const selectType = (type: CTIType) => { const selectType = (type: CTIType) => {
setSelectedType(type) setSelectedType(type);
fetchItems(type.id) };
}
const openAdd = (panel: 'category' | 'type' | 'item') => { const openAdd = (panel: Panel) => {
setNameValue('') setNameValue('');
setNameModal({ open: true, mode: 'add', panel }) setNameModal({ open: true, mode: 'add', panel });
} };
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => { const openEdit = (panel: Panel, item: PanelItem) => {
setNameValue(item.name) setNameValue(item.name);
setNameModal({ open: true, mode: 'edit', panel, item }) setNameModal({ open: true, mode: 'edit', panel, item });
} };
const closeModal = () => setNameModal((m) => ({ ...m, open: false })) const closeModal = () => setNameModal((m) => ({ ...m, open: false }));
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
if (!nameValue.trim()) return const name = nameValue.trim();
setSubmitting(true) if (!name) return;
try { const { mode, panel, item } = nameModal;
const { mode, panel, item } = nameModal
if (mode === 'add') { if (mode === 'add') {
if (panel === 'category') { if (panel === 'category') {
await api.post('/cti/categories', { name: nameValue.trim() }) await createCategory.mutateAsync({ name });
await fetchCategories()
} else if (panel === 'type' && selectedCategory) { } else if (panel === 'type' && selectedCategory) {
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id }) await createType.mutateAsync({ name, categoryId: selectedCategory.id });
await fetchTypes(selectedCategory.id)
} else if (panel === 'item' && selectedType) { } else if (panel === 'item' && selectedType) {
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id }) await createItem.mutateAsync({ name, typeId: selectedType.id });
await fetchItems(selectedType.id)
} }
} else { } else if (item) {
if (!item) return
if (panel === 'category') { if (panel === 'category') {
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() }) await updateCategory.mutateAsync({ id: item.id, name });
await fetchCategories() if (selectedCategory?.id === item.id)
if (selectedCategory?.id === item.id) setSelectedCategory((c) => c ? { ...c, name: nameValue.trim() } : c) setSelectedCategory((c) => (c ? { ...c, name } : c));
} else if (panel === 'type') { } else if (panel === 'type') {
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() }) await updateType.mutateAsync({ id: item.id, name });
if (selectedCategory) await fetchTypes(selectedCategory.id) if (selectedType?.id === item.id) setSelectedType((t) => (t ? { ...t, name } : t));
if (selectedType?.id === item.id) setSelectedType((t) => t ? { ...t, name: nameValue.trim() } : t)
} else if (panel === 'item') { } else if (panel === 'item') {
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() }) await updateItem.mutateAsync({ id: item.id, name });
if (selectedType) await fetchItems(selectedType.id)
}
}
closeModal()
} finally {
setSubmitting(false)
} }
} }
closeModal();
};
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => { const handleDelete = async (panel: Panel, item: PanelItem) => {
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return;
if (panel === 'category') { if (panel === 'category') {
await api.delete(`/cti/categories/${item.id}`) await deleteCategory.mutateAsync(item.id);
if (selectedCategory?.id === item.id) { if (selectedCategory?.id === item.id) {
setSelectedCategory(null) setSelectedCategory(null);
setSelectedType(null) setSelectedType(null);
setTypes([])
setItems([])
} }
await fetchCategories()
} else if (panel === 'type') { } else if (panel === 'type') {
await api.delete(`/cti/types/${item.id}`) await deleteType.mutateAsync(item.id);
if (selectedType?.id === item.id) { if (selectedType?.id === item.id) setSelectedType(null);
setSelectedType(null)
setItems([])
}
if (selectedCategory) await fetchTypes(selectedCategory.id)
} else { } else {
await api.delete(`/cti/items/${item.id}`) await deleteItem.mutateAsync(item.id);
if (selectedType) await fetchItems(selectedType.id)
}
} }
};
const panelClass = 'bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col' const panelClass = 'bg-gray-900 border border-gray-800 rounded-xl overflow-hidden flex flex-col';
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800' const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-800';
const itemClass = (active: boolean) => const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${ `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' : 'hover:bg-gray-800 border-l-2 border-transparent' active
}` ? 'bg-indigo-600/20 border-l-2 border-indigo-500'
: 'hover:bg-gray-800 border-l-2 border-transparent'
}`;
return ( return (
<Layout title="CTI Configuration"> <Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:h-[calc(100vh-10rem)]">
{/* Categories */} {/* Categories */}
<div className={panelClass}> <div className={panelClass}>
<div className={panelHeaderClass}> <div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-300">Categories</h3> <h3 className="text-sm font-semibold text-gray-300">Categories</h3>
<button <button
onClick={() => openAdd('category')} 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} /> <Plus size={16} />
</button> </button>
@@ -159,13 +162,19 @@ export default function AdminCTI() {
<span className="text-sm text-gray-300">{cat.name}</span> <span className="text-sm text-gray-300">{cat.name}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }} onClick={(e) => {
e.stopPropagation();
openEdit('category', cat);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }} onClick={(e) => {
e.stopPropagation();
handleDelete('category', cat);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
@@ -190,7 +199,7 @@ export default function AdminCTI() {
{selectedCategory && ( {selectedCategory && (
<button <button
onClick={() => openAdd('type')} 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} /> <Plus size={16} />
</button> </button>
@@ -211,13 +220,19 @@ export default function AdminCTI() {
<span className="text-sm text-gray-300">{type.name}</span> <span className="text-sm text-gray-300">{type.name}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }} onClick={(e) => {
e.stopPropagation();
openEdit('type', type);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }} onClick={(e) => {
e.stopPropagation();
handleDelete('type', type);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
@@ -242,7 +257,7 @@ export default function AdminCTI() {
{selectedType && ( {selectedType && (
<button <button
onClick={() => openAdd('item')} 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} /> <Plus size={16} />
</button> </button>
@@ -259,13 +274,19 @@ export default function AdminCTI() {
<span className="text-sm text-gray-300">{item.name}</span> <span className="text-sm text-gray-300">{item.name}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }} onClick={(e) => {
e.stopPropagation();
openEdit('item', item);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-gray-300 transition-all"
> >
<Pencil size={13} /> <Pencil size={13} />
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }} onClick={(e) => {
e.stopPropagation();
handleDelete('item', item);
}}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all" className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
> >
<Trash2 size={13} /> <Trash2 size={13} />
@@ -293,7 +314,7 @@ export default function AdminCTI() {
onChange={(e) => setNameValue(e.target.value)} onChange={(e) => setNameValue(e.target.value)}
required required
autoFocus 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>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@@ -307,7 +328,7 @@ export default function AdminCTI() {
<button <button
type="submit" type="submit"
disabled={submitting} 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'} {submitting ? 'Saving...' : 'Save'}
</button> </button>
@@ -316,5 +337,5 @@ export default function AdminCTI() {
</Modal> </Modal>
)} )}
</Layout> </Layout>
) );
} }
+169 -106
View File
@@ -1,17 +1,34 @@
import { useState, useEffect } from 'react' import { useState } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react' import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react';
import api from '../../api/client' import { toast } from 'sonner';
import Layout from '../../components/Layout' import Layout from '../../components/Layout';
import Modal from '../../components/Modal' import Modal from '../../components/Modal';
import { User, Role } from '../../types' import { User, Role } from '../../types';
import { useAuth } from '../../contexts/AuthContext' import type { CreateUserInput } from '../../../../shared/schemas/user';
import { useAuth } from '../../contexts/AuthContext';
import {
useUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from '../../api/queries';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface UserForm { interface UserForm {
username: string username: string;
email: string email: string;
displayName: string displayName: string;
password: string password: string;
role: Role role: Role;
} }
const BLANK_FORM: UserForm = { const BLANK_FORM: UserForm = {
@@ -20,136 +37,145 @@ const BLANK_FORM: UserForm = {
displayName: '', displayName: '',
password: '', password: '',
role: 'AGENT', role: 'AGENT',
} };
const ROLE_LABELS: Record<Role, string> = { const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin', ADMIN: 'Admin',
AGENT: 'Agent', AGENT: 'Agent',
USER: 'User', USER: 'User',
SERVICE: 'Service', };
}
const ROLE_BADGE: Record<Role, string> = { const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30', ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-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', 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> = { const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets', 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', USER: 'Basic access — view tickets and add comments only',
SERVICE: 'Automation account — authenticates via API key, no password login', };
}
export default function AdminUsers() { export default function AdminUsers() {
const { user: authUser } = useAuth() const { user: authUser } = useAuth();
const [users, setUsers] = useState<User[]>([]) const { data: users = [] } = useUsers();
const [modal, setModal] = useState<'add' | 'edit' | null>(null) const createUser = useCreateUser();
const [selected, setSelected] = useState<User | null>(null) const updateUser = useUpdateUser();
const [form, setForm] = useState<UserForm>(BLANK_FORM) const deleteUser = useDeleteUser();
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [newApiKey, setNewApiKey] = useState<string | null>(null)
const fetchUsers = () => { const [modal, setModal] = useState<'add' | 'edit' | null>(null);
api.get<User[]>('/users').then((r) => setUsers(r.data)) const [selected, setSelected] = useState<User | null>(null);
} const [form, setForm] = useState<UserForm>(BLANK_FORM);
const [error, setError] = useState('');
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [rotating, setRotating] = useState<User | null>(null);
useEffect(() => { fetchUsers() }, []) const submitting = createUser.isPending || updateUser.isPending;
const openAdd = () => { const openAdd = () => {
setForm(BLANK_FORM) setForm(BLANK_FORM);
setError('') setError('');
setNewApiKey(null) setNewApiKey(null);
setModal('add') setModal('add');
} };
const openEdit = (u: User) => { const openEdit = (u: User) => {
setSelected(u) setSelected(u);
setForm({ username: u.username, email: u.email, displayName: u.displayName, password: '', role: u.role }) setForm({
setError('') username: u.username,
setNewApiKey(null) email: u.email,
setModal('edit') displayName: u.displayName,
} password: '',
role: u.role,
});
setError('');
setNewApiKey(null);
setModal('edit');
};
const closeModal = () => { const closeModal = () => {
setModal(null) setModal(null);
setSelected(null) setSelected(null);
setNewApiKey(null) setNewApiKey(null);
fetchUsers() };
}
const handleAdd = async (e: React.FormEvent) => { const handleAdd = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setSubmitting(true) setError('');
setError('')
try { try {
const payload: Record<string, string> = { const created = await createUser.mutateAsync({
username: form.username, username: form.username,
email: form.email, email: form.email,
displayName: form.displayName, displayName: form.displayName,
role: form.role, role: form.role as CreateUserInput['role'],
} password: form.password,
if (form.password) payload.password = form.password });
const res = await api.post<User>('/users', payload) if (created.apiKey) setNewApiKey(created.apiKey);
if (res.data.apiKey) setNewApiKey(res.data.apiKey) else closeModal();
else closeModal()
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } } const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to create user') setError(e.response?.data?.error ?? 'Failed to create user');
} finally {
setSubmitting(false)
}
} }
};
const handleEdit = async (e: React.FormEvent) => { const handleEdit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
if (!selected) return if (!selected) return;
setSubmitting(true) setError('');
setError('')
try { try {
const payload: Record<string, string> = { const payload: Record<string, string> = {
email: form.email, email: form.email,
displayName: form.displayName, displayName: form.displayName,
role: form.role, role: form.role,
} };
if (form.password) payload.password = form.password if (form.password) payload.password = form.password;
await api.patch(`/users/${selected.id}`, payload) await updateUser.mutateAsync({ id: selected.id, data: payload });
closeModal() closeModal();
} catch (err: unknown) { } catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } } const e = err as { response?: { data?: { error?: string } } };
setError(e.response?.data?.error ?? 'Failed to update user') setError(e.response?.data?.error ?? 'Failed to update user');
} finally {
setSubmitting(false)
}
} }
};
const handleDelete = async (u: User) => { const confirmDelete = async () => {
if (!confirm(`Delete user "${u.displayName}"?`)) return if (!deleting) return;
await api.delete(`/users/${u.id}`) try {
fetchUsers() await deleteUser.mutateAsync(deleting.id);
toast.success(`Deleted ${deleting.displayName}`);
} catch (e) {
toast.error((e as Error).message || 'Failed to delete user');
} }
setDeleting(null);
};
const handleRegenerateKey = async (u: User) => { const confirmRegenerate = async () => {
if (!confirm(`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`)) return if (!rotating) return;
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true }) try {
setNewApiKey(res.data.apiKey ?? null) const updated = await updateUser.mutateAsync({
setSelected(u) id: rotating.id,
setModal('edit') data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(rotating);
setModal('edit');
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate key');
} }
setRotating(null);
};
const copyToClipboard = (key: string) => { const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key) navigator.clipboard.writeText(key);
setCopiedKey(key) setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000) setTimeout(() => setCopiedKey(null), 2000);
} };
const inputClass = 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 labelClass = 'block text-sm font-medium text-gray-300 mb-1';
return ( return (
<Layout <Layout
@@ -157,15 +183,15 @@ export default function AdminUsers() {
action={ action={
<button <button
onClick={openAdd} 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} /> <Plus size={14} />
Add User Add User
</button> </button>
} }
> >
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-gray-800"> <thead className="border-b border-gray-800">
<tr> <tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide"> <th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
@@ -189,16 +215,18 @@ export default function AdminUsers() {
<td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td> <td className="px-5 py-3 font-medium text-gray-100">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td> <td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}> <span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium border ${ROLE_BADGE[u.role]}`}
>
{ROLE_LABELS[u.role]} {ROLE_LABELS[u.role]}
</span> </span>
</td> </td>
<td className="px-5 py-3 text-gray-400">{u.email}</td> <td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && ( {u.role === 'AGENT' && (
<button <button
onClick={() => handleRegenerateKey(u)} onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors" className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key" title="Regenerate API key"
> >
@@ -213,7 +241,7 @@ export default function AdminUsers() {
</button> </button>
{u.id !== authUser?.id && ( {u.id !== authUser?.id && (
<button <button
onClick={() => handleDelete(u)} onClick={() => setDeleting(u)}
className="text-gray-600 hover:text-red-400 transition-colors" className="text-gray-600 hover:text-red-400 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
@@ -237,7 +265,7 @@ export default function AdminUsers() {
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4"> <div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4">
<p className="text-sm font-medium text-amber-400 mb-2"> <p className="text-sm font-medium text-amber-400 mb-2">
API Key copy it now, it won't be shown again API Key copy it now, it won&apos;t be shown again
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all"> <code className="flex-1 text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-3 py-2 font-mono break-all">
@@ -253,7 +281,7 @@ export default function AdminUsers() {
</div> </div>
<button <button
onClick={closeModal} 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 Done
</button> </button>
@@ -312,7 +340,7 @@ export default function AdminUsers() {
type="password" type="password"
value={form.password} value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add' && form.role !== 'SERVICE'} required={modal === 'add'}
className={inputClass} className={inputClass}
placeholder={modal === 'edit' ? '••••••••' : ''} placeholder={modal === 'edit' ? '••••••••' : ''}
/> />
@@ -328,7 +356,6 @@ export default function AdminUsers() {
<option value="AGENT">Agent</option> <option value="AGENT">Agent</option>
<option value="USER">User</option> <option value="USER">User</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="SERVICE">Service</option>
</select> </select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p> <p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div> </div>
@@ -344,7 +371,7 @@ export default function AdminUsers() {
<button <button
type="submit" type="submit"
disabled={submitting} 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'} {submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
</button> </button>
@@ -353,6 +380,42 @@ export default function AdminUsers() {
)} )}
</Modal> </Modal>
)} )}
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. Their tickets and comments are preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate API key for {rotating?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You&apos;ll see the new key once make
sure whatever uses it can be updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRegenerate}>Rotate key</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout> </Layout>
) );
} }
+304
View File
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { Copy, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { WEBHOOK_EVENTS } from '../../../../shared/schemas/notification';
import type { Webhook } from '../../../../shared/types';
import {
useWebhooks,
useCreateWebhook,
useDeleteWebhook,
useUpdateWebhook,
useRotateWebhookSecret,
} 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';
const BLANK = {
name: '',
url: '',
events: [...WEBHOOK_EVENTS] as string[],
};
export default function AdminWebhooks() {
const { data: hooks = [] } = useWebhooks();
const create = useCreateWebhook();
const update = useUpdateWebhook();
const del = useDeleteWebhook();
const rotate = useRotateWebhookSecret();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState(BLANK);
const [revealedSecret, setRevealedSecret] = useState<{ name: string; secret: string } | null>(
null,
);
const [deleting, setDeleting] = useState<Webhook | null>(null);
const [rotating, setRotating] = useState<Webhook | null>(null);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const toggleEvent = (event: string) => {
setForm((f) => ({
...f,
events: f.events.includes(event)
? f.events.filter((e) => e !== event)
: [...f.events, event],
}));
};
const submitCreate = async () => {
try {
const hook = await create.mutateAsync(form);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
setForm(BLANK);
setAddOpen(false);
toast.success('Webhook created');
} catch (e) {
toast.error((e as Error).message || 'Failed to create webhook');
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await del.mutateAsync(deleting.id);
toast.success('Webhook deleted');
} catch (e) {
toast.error((e as Error).message || 'Failed to delete');
}
setDeleting(null);
};
const confirmRotate = async () => {
if (!rotating) return;
try {
const hook = await rotate.mutateAsync(rotating.id);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate');
}
setRotating(null);
};
return (
<Layout
title="Webhooks"
action={
<button
onClick={() => setAddOpen(true)}
className="flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm"
>
<Plus size={14} />
Add webhook
</button>
}
>
{hooks.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground border border-border rounded-md">
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div>
) : (
<div className="rounded-md border border-border overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr>
<th className="text-left px-4 py-2">Name</th>
<th className="text-left px-4 py-2">URL</th>
<th className="text-left px-4 py-2">Events</th>
<th className="text-left px-4 py-2">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{hooks.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 font-medium">{h.name}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground truncate max-w-xs">
{h.url}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{h.events.length} event{h.events.length === 1 ? '' : 's'}
</td>
<td className="px-4 py-2">
<button
onClick={() => update.mutate({ id: h.id, data: { active: !h.active } })}
className={`text-xs px-2 py-0.5 rounded-full ${
h.active
? 'bg-green-500/20 text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{h.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setRotating(h)}
title="Rotate secret"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setDeleting(h)}
title="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add webhook</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Name</label>
<input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
URL
</label>
<input
value={form.url}
onChange={(e) => setForm((f) => ({ ...f, url: e.target.value }))}
placeholder="https://n8n.example.com/webhook/abc"
className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Events
</label>
<div className="grid grid-cols-2 gap-1">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.events.includes(ev)}
onChange={() => toggleEvent(ev)}
/>
<span className="font-mono text-xs">{ev}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setAddOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitCreate}
disabled={!form.name || !form.url || form.events.length === 0 || create.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Create
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!revealedSecret} onOpenChange={(o) => !o && setRevealedSecret(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Secret for {revealedSecret?.name}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Copy it now it won&apos;t be shown again. Use it to verify the{' '}
<code className="text-xs">X-Ticketing-Signature</code> HMAC-SHA256 header.
</p>
<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">{revealedSecret?.secret}</span>
<button
onClick={() => revealedSecret && copy(revealedSecret.secret)}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<DialogFooter>
<button
onClick={() => setRevealedSecret(null)}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm"
>
Done
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook {deleting?.name}?</AlertDialogTitle>
<AlertDialogDescription>
No events will be dispatched to this URL anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Rotate secret for {rotating?.name}?</AlertDialogTitle>
<AlertDialogDescription>
The existing secret stops signing new events immediately. You&apos;ll see the new
secret once.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRotate}>Rotate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}
@@ -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';
+7
View File
@@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
+53 -53
View File
@@ -1,74 +1,74 @@
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE' export type Role = 'ADMIN' | 'AGENT' | 'USER';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED' export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export interface User { export interface User {
id: string id: string;
username: string username: string;
displayName: string displayName: string;
email: string email: string;
role: Role role: Role;
apiKey?: string apiKey?: string;
createdAt?: string createdAt?: string;
} }
export interface Category { export interface Category {
id: string id: string;
name: string name: string;
} }
export interface CTIType { export interface CTIType {
id: string id: string;
name: string name: string;
categoryId: string categoryId: string;
category?: Category category?: Category;
} }
export interface Item { export interface Item {
id: string id: string;
name: string name: string;
typeId: string typeId: string;
type?: CTIType & { category?: Category } type?: CTIType & { category?: Category };
} }
export interface Comment { export interface Comment {
id: string id: string;
body: string body: string;
ticketId: string ticketId: string;
authorId: string authorId: string;
author: Pick<User, 'id' | 'username' | 'displayName'> author: Pick<User, 'id' | 'username' | 'displayName'>;
createdAt: string createdAt: string;
} }
export interface AuditLog { export interface AuditLog {
id: string id: string;
ticketId: string ticketId: string;
userId: string userId: string;
action: string action: string;
detail: string | null detail: string | null;
createdAt: string createdAt: string;
user: Pick<User, 'id' | 'username' | 'displayName'> user: Pick<User, 'id' | 'username' | 'displayName'>;
} }
export interface Ticket { export interface Ticket {
id: string id: string;
displayId: string displayId: string;
title: string title: string;
overview: string overview: string;
severity: number severity: number;
status: TicketStatus status: TicketStatus;
categoryId: string categoryId: string;
typeId: string typeId: string;
itemId: string itemId: string;
assigneeId: string | null assigneeId: string | null;
createdById: string createdById: string;
resolvedAt: string | null resolvedAt: string | null;
createdAt: string createdAt: string;
updatedAt: string updatedAt: string;
category: Category category: Category;
type: CTIType type: CTIType;
item: Item item: Item;
assignee: Pick<User, 'id' | 'username' | 'displayName'> | null assignee: Pick<User, 'id' | 'username' | 'displayName'> | null;
createdBy: Pick<User, 'id' | 'username' | 'displayName'> createdBy: Pick<User, 'id' | 'username' | 'displayName'>;
comments?: Comment[] comments?: Comment[];
_count?: { comments: number } _count?: { comments: number };
} }
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+68 -3
View File
@@ -1,8 +1,73 @@
import tailwindcssAnimate from 'tailwindcss-animate';
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { theme: {
extend: {}, container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
}, },
plugins: [], },
} extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [tailwindcssAnimate],
};
+5 -2
View File
@@ -14,7 +14,10 @@
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src"] "include": ["src", "../shared/**/*"]
} }
+9 -3
View File
@@ -1,11 +1,17 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:3000', '/api': 'http://localhost:3000',
}, },
}, },
}) });
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
css: false,
},
});
+18 -2
View File
@@ -15,7 +15,7 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -30,9 +30,24 @@ services:
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
CLIENT_URL: ${CLIENT_URL} CLIENT_URL: ${CLIENT_URL}
PORT: 3000 PORT: 3000
UPLOADS_DIR: /data/uploads
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-noreply@localhost}
SMTP_SECURE: ${SMTP_SECURE:-false}
volumes:
- uploads:/data/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:3000/healthz || exit 1']
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks: networks:
- internal - internal
@@ -40,7 +55,7 @@ services:
image: ${REGISTRY}/josh/ticketing-client:${TAG:-latest} image: ${REGISTRY}/josh/ticketing-client:${TAG:-latest}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${PORT:-3080}:80" - '${PORT:-3080}:80'
depends_on: depends_on:
- server - server
networks: networks:
@@ -52,3 +67,4 @@ networks:
volumes: volumes:
postgres_data: postgres_data:
uploads:
+78
View File
@@ -0,0 +1,78 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/.vite/**',
'**/coverage/**',
'server/prisma/migrations/**',
'client/src/components/ui/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['client/src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.browser },
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
react,
'react-hooks': reactHooks,
},
settings: {
react: { version: 'detect' },
},
rules: {
...react.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['server/src/**/*.ts', 'server/prisma/**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: { ...globals.node },
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['shared/**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
rules: {
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
},
},
];
+3518
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ticketing-system",
"private": true,
"version": "1.0.0",
"description": "Internal ticketing system — monorepo root (tooling only)",
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "npm run typecheck --prefix server && npm run typecheck --prefix client",
"test": "npm test --prefix server && npm test --prefix client"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^5.1.0",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"typescript-eslint": "^8.18.2"
}
}
+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" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ticketing"
JWT_SECRET="change-this-to-a-long-random-secret" JWT_SECRET="change-this-to-a-long-random-secret"
CLIENT_URL="http://localhost:5173" CLIENT_URL="http://localhost:5173"
PORT=3000 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
+10 -9
View File
@@ -1,16 +1,17 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci COPY server/package*.json ./server/
COPY . . RUN npm ci --omit=dev && cd server && npm ci
RUN npx prisma generate COPY server ./server
RUN npm run build COPY shared ./shared
RUN cd server && npx prisma generate && npm run build
FROM node:22-alpine FROM node:22-alpine
RUN apk add --no-cache openssl RUN apk add --no-cache openssl
WORKDIR /app WORKDIR /app/server
COPY --from=build /app/dist ./dist COPY --from=build /app/server/dist ./dist
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/server/node_modules ./node_modules
COPY --from=build /app/prisma ./prisma COPY --from=build /app/server/prisma ./prisma
COPY package*.json ./ COPY --from=build /app/server/package*.json ./
CMD ["npm", "run", "start:prod"] CMD ["npm", "run", "start:prod"]
+2197 -1
View File
File diff suppressed because it is too large Load Diff
+19 -6
View File
@@ -4,12 +4,14 @@
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/server/src/index.js",
"start:prod": "prisma db push && node dist/index.js", "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:migrate": "prisma migrate dev", "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:push": "prisma db push",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts" "db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
@@ -19,7 +21,12 @@
"express": "^4.21.2", "express": "^4.21.2",
"express-async-errors": "^3.1.0", "express-async-errors": "^3.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^8.0.5",
"pino": "^9.5.0",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -27,10 +34,16 @@
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^6.0.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"supertest": "^7.0.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.2",
"vitest": "^2.1.8",
"vitest-mock-extended": "^2.0.0"
} }
} }
+52
View File
@@ -0,0 +1,52 @@
-- Idempotent SQL applied after `prisma db push`.
-- Adds Postgres full-text-search columns + triggers + GIN indexes for Ticket and Comment.
-- Prisma can't express tsvector/triggers, so we manage them here.
-- Ticket.searchVector
ALTER TABLE "Ticket" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS ticket_search_idx ON "Ticket" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION ticket_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" :=
setweight(to_tsvector('english', coalesce(NEW."displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."title", '')), 'A') ||
setweight(to_tsvector('english', coalesce(NEW."overview", '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ticket_search_update ON "Ticket";
CREATE TRIGGER ticket_search_update
BEFORE INSERT OR UPDATE OF "title", "overview", "displayId" ON "Ticket"
FOR EACH ROW EXECUTE FUNCTION ticket_search_trigger();
-- Backfill any rows missing the vector (first run or new columns)
UPDATE "Ticket"
SET "searchVector" =
setweight(to_tsvector('english', coalesce("displayId", '')), 'A') ||
setweight(to_tsvector('english', coalesce("title", '')), 'A') ||
setweight(to_tsvector('english', coalesce("overview", '')), 'B')
WHERE "searchVector" IS NULL;
-- Comment.searchVector
ALTER TABLE "Comment" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
CREATE INDEX IF NOT EXISTS comment_search_idx ON "Comment" USING GIN ("searchVector");
CREATE OR REPLACE FUNCTION comment_search_trigger() RETURNS trigger AS $$
BEGIN
NEW."searchVector" := to_tsvector('english', coalesce(NEW."body", ''));
RETURN NEW;
END
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS comment_search_update ON "Comment";
CREATE TRIGGER comment_search_update
BEFORE INSERT OR UPDATE OF "body" ON "Comment"
FOR EACH ROW EXECUTE FUNCTION comment_search_trigger();
UPDATE "Comment"
SET "searchVector" = to_tsvector('english', coalesce("body", ''))
WHERE "searchVector" IS NULL;
+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 $$;
+88 -1
View File
@@ -11,7 +11,6 @@ enum Role {
ADMIN ADMIN
AGENT AGENT
USER USER
SERVICE
} }
enum TicketStatus { enum TicketStatus {
@@ -29,6 +28,7 @@ model User {
displayName String displayName String
role Role @default(AGENT) role Role @default(AGENT)
apiKey String? @unique apiKey String? @unique
notificationPrefs Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -36,6 +36,9 @@ model User {
createdTickets Ticket[] @relation("CreatedTickets") createdTickets Ticket[] @relation("CreatedTickets")
comments Comment[] comments Comment[]
auditLogs AuditLog[] auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
savedViews SavedView[]
} }
model Category { model Category {
@@ -54,6 +57,7 @@ model Type {
tickets Ticket[] tickets Ticket[]
@@unique([categoryId, name]) @@unique([categoryId, name])
@@index([categoryId])
} }
model Item { model Item {
@@ -64,6 +68,7 @@ model Item {
tickets Ticket[] tickets Ticket[]
@@unique([typeId, name]) @@unique([typeId, name])
@@index([typeId])
} }
model Ticket { model Ticket {
@@ -83,6 +88,9 @@ model Ticket {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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]) category Category @relation(fields: [categoryId], references: [id])
type Type @relation(fields: [typeId], references: [id]) type Type @relation(fields: [typeId], references: [id])
item Item @relation(fields: [itemId], references: [id]) item Item @relation(fields: [itemId], references: [id])
@@ -90,6 +98,14 @@ model Ticket {
createdBy User @relation("CreatedTickets", fields: [createdById], references: [id]) createdBy User @relation("CreatedTickets", fields: [createdById], references: [id])
comments Comment[] comments Comment[]
auditLogs AuditLog[] auditLogs AuditLog[]
attachments Attachment[]
notifications Notification[]
@@index([status])
@@index([severity])
@@index([assigneeId])
@@index([createdById])
@@index([createdAt])
} }
model Comment { model Comment {
@@ -99,8 +115,77 @@ model Comment {
authorId String authorId String
createdAt DateTime @default(now()) 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) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id]) author User @relation(fields: [authorId], references: [id])
attachments Attachment[]
notifications Notification[]
@@index([ticketId])
}
model Attachment {
id String @id @default(cuid())
filename String
mimetype String
size Int
storagePath String
ticketId String?
commentId String?
uploadedById String
createdAt DateTime @default(now())
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
uploadedBy User @relation(fields: [uploadedById], references: [id])
@@index([ticketId])
@@index([commentId])
@@index([uploadedById])
}
model Webhook {
id String @id @default(cuid())
name String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Notification {
id String @id @default(cuid())
userId String
kind String
ticketId String?
commentId String?
data Json?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ticket Ticket? @relation(fields: [ticketId], references: [id], onDelete: Cascade)
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([userId, readAt])
@@index([userId, createdAt])
}
model SavedView {
id String @id @default(cuid())
userId String
name String
filters Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
} }
model AuditLog { model AuditLog {
@@ -113,4 +198,6 @@ model AuditLog {
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
@@index([ticketId, createdAt])
} }

Some files were not shown because too many files have changed in this diff Show More