Compare commits
32 Commits
2a6090e473
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d9464a6fb | |||
| c6ec47a8fc | |||
| cfe7ad56ff | |||
| 2177162300 | |||
| 7f50783600 | |||
| 6c93a8c466 | |||
| c0ff063023 | |||
| 86399c4ed0 | |||
| d3ec27e223 | |||
| b32e1dfa57 | |||
| 104f7773ba | |||
| 28274bf7bd | |||
| 5acc252921 | |||
| a9bf332369 | |||
| f98930b54f | |||
| f7028c563a | |||
| d8785a964d | |||
| a9ba74f1af | |||
| b341c64b02 | |||
| 2c11d19f76 | |||
| 186dcc4686 | |||
| 8c2139f6a6 | |||
| 7253068fee | |||
| ef22e92ac8 | |||
| 4bade22410 | |||
| edf4c5eb3c | |||
| 0806aec4a4 | |||
| 77679922a8 | |||
| 17697ecf3b | |||
| 4eae11b5b0 | |||
| aff52e5672 | |||
| 27d2ab0f0d |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.vite
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
package-lock.json
|
||||||
|
server/prisma/migrations
|
||||||
|
client/src/components/ui
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -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 1–5** — SEV 1 (critical) through SEV 5 (minimal); dashboard sorts by severity
|
- **Severity 1–5** — 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
@@ -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;"]
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Generated
+4045
-72
File diff suppressed because it is too large
Load Diff
+30
-2
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ export default {
|
|||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 |
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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})`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
};
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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'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'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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './TicketDetail';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './Tickets';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
+53
-53
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
|||||||
@@ -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/**/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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: '^_' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
Generated
+3518
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
Generated
+2197
-1
File diff suppressed because it is too large
Load Diff
+19
-6
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Idempotent SQL applied BEFORE `prisma db push`.
|
||||||
|
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
|
||||||
|
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_type t
|
||||||
|
JOIN pg_enum e ON e.enumtypid = t.oid
|
||||||
|
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
|
||||||
|
) THEN
|
||||||
|
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -11,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
Reference in New Issue
Block a user