Initial import of Forge app
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# OPTIONAL per-machine deploy overrides for `npm run deploy` (scripts/push-to-nas.sh).
|
||||
# The script already has baked-in defaults for the FORGE NAS, so this file is only
|
||||
# needed to override them. Copy to `.deploy.env` (gitignored) and uncomment what
|
||||
# you need. Holds NO app secrets — the app's `.env` lives on the NAS.
|
||||
|
||||
# NAS_HOST=mycloud.dp.ua # NAS hostname (public host; or a LAN IP like 192.168.50.2)
|
||||
# NAS_USER=d.tkachenko # SSH user on the NAS
|
||||
# NAS_PORT=2323 # Synology SSH port
|
||||
# NAS_PATH=/volume1/docker/forge # deploy dir on the NAS (must contain the NAS .env)
|
||||
# NAS_KEY=$HOME/.ssh/id_ed25519 # SSH private key (IdentitiesOnly is pinned)
|
||||
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
client/node_modules
|
||||
dist
|
||||
client/dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
# macOS AppleDouble sidecars — NUL-byte xattr files that break esbuild if copied.
|
||||
**/._*
|
||||
._*
|
||||
storage-dump.json
|
||||
.deploy.env
|
||||
claude_artifacts
|
||||
extension
|
||||
README.md
|
||||
docs
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/tmp
|
||||
/out-tsc
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/client/node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# build output
|
||||
/dist
|
||||
/client/dist
|
||||
|
||||
# env / secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# raw Let it Snow storage dump — a dev artifact with real PII, not an app input.
|
||||
# Convert it to the seed archives with `npm run dump-to-archives` instead.
|
||||
storage-dump.json
|
||||
|
||||
# editor
|
||||
.vscode/*
|
||||
.DS_Store
|
||||
|
||||
# NAS deploy config (not secret, but host-specific)
|
||||
.deploy.env
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/FORGE.iml" filepath="$PROJECT_DIR$/.idea/FORGE.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **FORGE** are documented here.
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/); this project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [2.3.2] — 2026-08-29
|
||||
|
||||
### Changed
|
||||
- **PM Insights — full-width category items.** Inside **Alert categories** and
|
||||
**Watch list**, each category is now a **full-width accordion** stacked in a single
|
||||
column (was a narrow multi-column grid), with more breathing room: larger inter-card
|
||||
gap, roomier card headers and ticket rows, and a taller expanded list. The click-to-
|
||||
expand behaviour per category is unchanged. (CSS-only: the `.grid` becomes a flex
|
||||
column; `.cardHead`/`.row` padding widened.)
|
||||
|
||||
## [2.3.1] — 2026-08-29
|
||||
|
||||
### Changed
|
||||
- **PM Insights — collapsible sections.** The **Alert categories** and **Watch list**
|
||||
sections are now accordions: each heading is a button (chevron + a category-count
|
||||
badge, `aria-expanded`/`aria-controls`) that collapses/expands its card grid. Both
|
||||
default to open; the per-card ticket expand and the **Per-PM roll-up** table are
|
||||
unchanged. Adds a `ChevronIcon` (rotates with the section) and a `.grid[hidden]` rule
|
||||
so a collapsed grid actually hides.
|
||||
|
||||
## [2.3.0] — 2026-08-27
|
||||
|
||||
### Added
|
||||
- **Jira status durations (chart #17)** — a new "Jira status durations (avg days)"
|
||||
chart on the Active-stats page: average time each Jira workflow status is held,
|
||||
in the board's column order, min-2-tickets. Server endpoint
|
||||
`GET /api/analytics/jira-durations` (PM+) aggregates `jira.statusDurations` (ms)
|
||||
across tickets.
|
||||
- The extension's Jira collector now fetches each issue's **changelog**
|
||||
(`expand=changelog`) and computes **`statusDurations`** (time per status) +
|
||||
**`movements`** (`{at, who, from, to}`), sent inside the `jira` object.
|
||||
- Seed enrichment: `statusDurations` + the Jira **workflow column order** are ingested
|
||||
from the dump's `jira_board_state` (so the chart is populated before any live sync —
|
||||
73 seeded tickets carry durations). `JiraInfo` type extended with `statusDurations`/`movements`.
|
||||
|
||||
## [2.2.0] — 2026-08-27
|
||||
|
||||
### Added
|
||||
- **Jira sync in the extension** (per the sync ADR). One "Sync now" now refreshes
|
||||
**SNOW then Jira**: SNOW stays as the in-page same-origin session collector →
|
||||
`/api/sync`; Jira is pulled by a new service-worker collector via **direct REST**
|
||||
(`/rest/agile/1.0/board/{id}/issue`) authenticated with a **Jira API token** —
|
||||
Basic (email+token, Cloud) or **Bearer PAT** (Server/DC, e.g. `support.dataart.com`).
|
||||
Issue links are built as `<baseUrl>/browse/<KEY>` (custom Jira domains supported).
|
||||
- New extension options: Jira base URL, email, API token/PAT, and board id(s).
|
||||
- New server endpoint **`POST /api/sync/jira`** — **attach-only**: updates only the
|
||||
`jira` JSONB (merged) + `jira_key` on existing tickets, keyed by RITM number; never
|
||||
touches status/state/assignee/activity (avoids the ticket-upsert clobber). Token-authed.
|
||||
Jira RITM↔issue resolved via `customfield_26001` or a `RITM\d+` regex; Jira failures are
|
||||
best-effort and never fail a successful SNOW sync.
|
||||
|
||||
|
||||
|
||||
### Added
|
||||
- **Polished Excel export** — the board's Excel export is now filterable and readable:
|
||||
the **ticket Number is a clickable ServiceNow hyperlink** (a real OOXML hyperlink
|
||||
relationship — locale-proof, unlike a `HYPERLINK()` formula which breaks in `;`-locale
|
||||
Excel), **auto-filter** on every column, **zebra-striped rows**, taller rows + wider
|
||||
columns, a frozen indigo header, right-aligned numbers, `#,##0` cost, short dates, and
|
||||
a **dated filename** (`forge-active-tickets-YYYY-MM-DD.xlsx`). The standalone Link column
|
||||
was folded into the Number cell. (auto-filter + hyperlinks injected via `fflate`.)
|
||||
- App **favicon** (indigo snowflake) + `theme-color`.
|
||||
- A proper **Excel-brand button icon** (green tile) replacing the placeholder glyph.
|
||||
|
||||
### Changed
|
||||
- RITM number links out to ServiceNow in the ticket table + board cards (new tab).
|
||||
- Deploy: ported Husky's `push-to-nas.sh` + on-NAS `deploy.sh` (SSH key pinning,
|
||||
openrsync→tar fallback, Synology PATH/sudo, health poll, `forge-db` volume preserved
|
||||
on `--fresh`); macOS AppleDouble (`._*`) files excluded from sync/build/tests.
|
||||
|
||||
### Dependencies
|
||||
- Added `fflate` (xlsx auto-filter post-processing). `write-excel-file` pinned at 2.x
|
||||
(v4 drops the default export + still has no hyperlink support).
|
||||
|
||||
## [2.0.0] — 2026-08-27 · **FORGE 2.0**
|
||||
|
||||
The analytics release. FORGE grows from a ticket board into a full ServiceNow
|
||||
analytics portal ported from the *Let it Snow* Chrome extension, with role-based
|
||||
access, admin tooling, and the complete statistics suite.
|
||||
|
||||
### Added
|
||||
- **Full analytics data ingest.** The seed now loads the entire `analytics_data`
|
||||
master (954 ticket records) merged with the operational cache, plus SLA norms,
|
||||
FX rates, size thresholds, brand palette, and the colleague roster. New ticket
|
||||
columns (all additive): `currency_code, opened_by, opened_date, closed_date,
|
||||
to_do_at, in_uat_at, jira_key, ticket_year, size, po_number, invoiced`. A
|
||||
reproducible `dump-to-archives` → `reseed` pipeline regenerates seed data from a
|
||||
storage dump. `app_config` key/value table for norms/thresholds/palettes.
|
||||
- **Server analytics engine** (`server/analytics.ts`, `server/insights.ts`):
|
||||
`/api/analytics/overview`, `/api/analytics/sla`, `/api/insights`, `/api/config`.
|
||||
- **Overall statistics** page — year-overlay grouped bars (opened/closed/revenue,
|
||||
bars↔line + YoY%), a Cost↔Tickets toggle, a ticket-share donut (#/% + top-N),
|
||||
brand→market and requester→brand drill-downs, KPI tiles, lifetime histogram.
|
||||
- **Active statistics** page — by-status, age distribution, opened-per-month,
|
||||
median days-in-status, by-brand.
|
||||
- **PM KPIs — SLA** page — six PM×size heatmaps (TTFR, PM response, avg-close,
|
||||
OTD, assign, preview) scored against per-size norms, green/red vs target.
|
||||
- **PM Insights** page — problem/alert KPIs over the active backlog (unassigned,
|
||||
on-hold, WIP-stalled, customer-replied, awaiting, lifetime, inactive), Jira-breach
|
||||
and client-owed rules, waiting-PO with L1/L2/L3 escalation, revenue-at-risk, and
|
||||
a per-PM roll-up. (Rev-at-risk matches the initial app's own figure within rounding.)
|
||||
- **Board redesign** — 7-state kanban (Unassigned → Open/Assigned → On Hold → WIP →
|
||||
Customer replied → Awaiting) with the WIP→"Customer replied" pseudo-status, SLA
|
||||
subtitles, per-column counts, brand/market/PM filters, board/list toggle, and a
|
||||
ticket detail modal.
|
||||
- **Role-based access control** — four roles (Viewer, PM, Project Leadership, Admin)
|
||||
enforced server-side per route; the client hides nav/pages above a user's role.
|
||||
Only admins can create admins.
|
||||
- **Admin page** — user management (Project Leadership+) and API-token management
|
||||
(Admin only): create/list/revoke `fg_…` sync tokens from the UI.
|
||||
- **Filtered Excel export** — the board's ⭳ Excel button exports the currently
|
||||
filtered tickets as a styled `.xlsx` (branded indigo frozen header, per-column
|
||||
widths, right-aligned numbers, `#,##0` cost, short dates, grid borders).
|
||||
- **Finance ingest** — cost + PO + invoiced per ticket from the finance sheet,
|
||||
enriching active tickets with cost/size and powering waiting-PO.
|
||||
- Config bundle exposed via `/api/config` (brand colours, colleagues, norms, FX).
|
||||
|
||||
### Changed
|
||||
- All analytics pages are **full-width** (removed the 1280px cap).
|
||||
- `/api/tokens` is now gated by the **admin session role** instead of an `ADMIN_KEY`
|
||||
header. The `mint-token` CLI is unchanged.
|
||||
- Non-destructive sync upsert: a thin sync no longer nulls seeded-rich fields
|
||||
(`COALESCE`/`CASE` preserve brand/market/description/jira/activity/analytics).
|
||||
|
||||
### Fixed
|
||||
- `due_date` timezone off-by-one (emit local `YYYY-MM-DD`, not `toISOString()`).
|
||||
- Activity render-crash vector (ingest sanitizes activity entries; `parseDate` guards).
|
||||
- Seed "active wins" ordering on tickets present in both archives.
|
||||
- Ticket-share donut used a truncated distribution (center 375); now the full 966.
|
||||
|
||||
### Security
|
||||
- Read-API authentication (session, username+password, bcrypt, Postgres session store).
|
||||
- Least-privilege database role (`forge_app`, not a superuser) owns the app tables.
|
||||
- API tokens: `fg_<id>_<secret>`, only the SHA-256 stored, constant-time verify,
|
||||
revocable; `token_hash` never leaked by the list endpoint.
|
||||
- RBAC hardening (post-audit): only an admin can delete an admin; the **last admin**
|
||||
cannot be deleted (lockout guard); deleting a user **purges their sessions**; the
|
||||
session id is **regenerated on login** (anti session-fixation); delete of a missing
|
||||
user returns 404.
|
||||
|
||||
## [1.0.0] — 2026-08-27
|
||||
|
||||
Initial full-stack scaffold, ported from the *Let it Snow* Chrome extension onto the
|
||||
*Husky repo list* template.
|
||||
|
||||
### Added
|
||||
- Vite + React 18 + TypeScript client (SCSS modules, react-router) with a Sidebar/
|
||||
TopBar layout; Active board, Closed, and basic Statistics pages.
|
||||
- Express + TypeScript server on Postgres (`forge`); self-bootstrapping schema
|
||||
(`initDB`) that seeds bundled ticket archives on first boot.
|
||||
- REST API: `/api/tickets`, `/api/tickets/:number`, `/api/stats`, `/healthz`, and a
|
||||
token-authed `/api/sync` ingest.
|
||||
- Chrome sync extension ("FORGE Snow Sync") — reads active RITM tickets from the
|
||||
ServiceNow Table API and pushes them to the portal with a bearer token.
|
||||
- Multi-stage Dockerfile + docker-compose (bundled Postgres) targeting
|
||||
`forge.mycloud.dp.ua`; README + SETUP docs.
|
||||
|
||||
[2.0.0]: #200--2026-08-27--forge-20
|
||||
[1.0.0]: #100--2026-08-27
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
#
|
||||
# FORGE — multi-stage build. Two sub-builds feed one slim runtime:
|
||||
# - client (Vite/React) -> client/dist (static assets)
|
||||
# - server (tsc) -> dist/index.js (Express API + static server)
|
||||
#
|
||||
# Base pinned to a specific patch; bump deliberately. The runtime carries only
|
||||
# prod deps (`--omit=dev`) + the two build outputs, and runs as the non-root
|
||||
# `node` user.
|
||||
|
||||
FROM node:20.19.6-alpine3.21 AS base
|
||||
|
||||
# --- Stage 1: client build --------------------------------------------------
|
||||
FROM base AS client-build
|
||||
WORKDIR /build/client
|
||||
COPY client/package.json client/package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY client/ ./
|
||||
RUN npm test
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: server build --------------------------------------------------
|
||||
FROM base AS server-build
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY tsconfig.json ./
|
||||
COPY index.ts ./
|
||||
COPY server/ ./server/
|
||||
RUN npx tsc
|
||||
|
||||
# --- Stage 3: prod deps -----------------------------------------------------
|
||||
FROM base AS prod-deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# --- Stage 4: runtime -------------------------------------------------------
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=server-build --chown=node:node /build/dist ./dist
|
||||
COPY --from=server-build --chown=node:node /build/server/data ./dist/server/data
|
||||
COPY --from=client-build --chown=node:node /build/client/dist ./client/dist
|
||||
COPY --chown=node:node package.json ./
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget --spider -q http://localhost:3000/healthz || exit 1
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# FORGE
|
||||
|
||||
A ServiceNow ticket dashboard for the *Marketing Web Presence* L3 queues —
|
||||
RITM/SCTASK requests pulled out of ServiceNow and shown as an operational board
|
||||
plus closed-ticket analytics. Ported from the **Let it Snow** Chrome extension
|
||||
into a full-stack web app, built on the shape of the *Husky repo list* template.
|
||||
|
||||
```
|
||||
┌──────────────┐ Bearer token ┌───────────────┐ Postgres
|
||||
│ Chrome ext │ ──── POST /api/sync ─▶│ FORGE server │ ────────────▶ forge DB
|
||||
│ (Snow Sync) │ │ Express + TS │
|
||||
└──────────────┘ └───────┬───────┘
|
||||
▲ │ serves
|
||||
reads ServiceNow ▼
|
||||
Table API in-page ┌───────────────────────────┐
|
||||
│ React client (Vite + TS) │
|
||||
│ Board · Closed · Stats │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
## Stack
|
||||
|
||||
- **client/** — React 18 + Vite + TypeScript, SCSS modules, react-router. Three
|
||||
views: **Active board** (filters, search, ticket detail + activity timeline),
|
||||
**Closed** (cost / time-to-first-reply analytics), **Statistics** (distribution).
|
||||
- **server** (`index.ts` + `server/`) — Express + TypeScript on Postgres. Schema
|
||||
self-bootstraps in `initDB()` and seeds the bundled ticket archives on first
|
||||
boot. REST: `GET /api/tickets`, `/api/tickets/:number`, `/api/stats`, `/healthz`,
|
||||
and a token-authed `POST /api/sync` ingest.
|
||||
- **extension/** — a Manifest V3 Chrome extension ("FORGE Snow Sync") that reads
|
||||
active RITM tickets from the ServiceNow Table API and pushes them to the server
|
||||
with an API token, exactly like the Husky sync extension.
|
||||
|
||||
## Quick start (local)
|
||||
|
||||
```bash
|
||||
# 1. a Postgres named `forge` (compose brings one up, or use your own)
|
||||
docker compose up -d db # or point DATABASE_URL at any Postgres
|
||||
|
||||
cp .env.example .env # DATABASE_URL defaults to the compose db
|
||||
|
||||
# 2. install + run both server and client with live reload
|
||||
npm install
|
||||
npm run dev # server :3000, client :5173 (proxies /api)
|
||||
```
|
||||
|
||||
Open http://localhost:5173 and sign in with the `AUTH_USER` / `AUTH_PASS` you
|
||||
set in `.env` (the read UI is auth-gated; see [docs/SETUP.md](docs/SETUP.md#3-log-in-read-api-auth)).
|
||||
On first boot the server seeds the ticket archives
|
||||
in `server/data/*.json` (**100 active + 866 closed** after de-duping the ~99
|
||||
tickets that appear in both), so the board is populated before any real sync
|
||||
runs. Those archives are generated from a Let it Snow storage dump — see
|
||||
[docs/SETUP.md](docs/SETUP.md#seed-data) to regenerate them.
|
||||
|
||||
## Production
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # app on host :3099, bundled Postgres
|
||||
```
|
||||
|
||||
Behind the Synology reverse proxy this is what **forge.mycloud.dp.ua** maps to.
|
||||
|
||||
See **[docs/SETUP.md](docs/SETUP.md)** for the sync token + Chrome extension setup.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # client (vitest) + server (vitest)
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>FORGE 2.0 — ServiceNow Tickets</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3725
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "forge-client",
|
||||
"private": true,
|
||||
"version": "2.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"fflate": "^0.8.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"sass": "^1.99.0",
|
||||
"write-excel-file": "^2.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#6366f1"/>
|
||||
<g stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
||||
<path d="M16 5.5V26.5M7 10.75 25 21.25M25 10.75 7 21.25"/>
|
||||
<path d="M16 9.5l3-2M16 9.5l-3-2M16 22.5l3 2M16 22.5l-3 2M9.5 12l-.4-3.4M22.5 12l.4-3.4M9.5 20l-.4 3.4M22.5 20l.4 3.4"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
@@ -0,0 +1,15 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
'sidebar topbar'
|
||||
'sidebar main';
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TopBar from './components/TopBar';
|
||||
import AuthWall from './components/AuthWall';
|
||||
import Board from './pages/Board';
|
||||
import Active from './pages/Active';
|
||||
import Closed from './pages/Closed';
|
||||
import Stats from './pages/Stats';
|
||||
import Overview from './pages/Overview';
|
||||
import SlaKpi from './pages/SlaKpi';
|
||||
import Insights from './pages/Insights';
|
||||
import Admin from './pages/Admin';
|
||||
import { roleAtLeast } from './services/auth.service';
|
||||
import { getStats } from './services/ticket.service';
|
||||
import { getMe, logout, type CurrentUser } from './services/auth.service';
|
||||
import { setUnauthorizedHandler } from './services/api.service';
|
||||
import styles from './App.module.scss';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [counts, setCounts] = useState({ active: 0, closed: 0 });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<string | null>(null);
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
try {
|
||||
const s = await getStats();
|
||||
setCounts({ active: s.active, closed: s.closed });
|
||||
setLastSync(new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }));
|
||||
} catch {
|
||||
// Sidebar counts are best-effort; pages surface their own load errors.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Bootstrap: who am I? Then load counts only when authenticated.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getMe()
|
||||
.then(me => { if (alive) { setUser(me); if (me) void loadCounts(); } })
|
||||
.catch(() => { if (alive) setUser(null); })
|
||||
.finally(() => { if (alive) setAuthReady(true); });
|
||||
return () => { alive = false; };
|
||||
}, [loadCounts]);
|
||||
|
||||
// Any API 401 drops us back to the login screen.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (user) void loadCounts(); }, [user, refreshKey, loadCounts]);
|
||||
|
||||
const handleLogin = useCallback((u: CurrentUser) => {
|
||||
setUser(u);
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout().catch(() => {});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setRefreshKey(k => k + 1);
|
||||
await loadCounts();
|
||||
setRefreshing(false);
|
||||
}, [loadCounts]);
|
||||
|
||||
if (!authReady) return null;
|
||||
if (!user) return <AuthWall onLogin={handleLogin} />;
|
||||
|
||||
const canReports = roleAtLeast(user.role, 'pm');
|
||||
const canUsers = roleAtLeast(user.role, 'lead');
|
||||
const reports = (el: React.ReactNode) => (canReports ? el : <Navigate to="/" replace />);
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<Sidebar activeCount={counts.active} closedCount={counts.closed} user={user} />
|
||||
<TopBar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
lastSync={lastSync}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<main className={styles.main}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Board search={search} refreshKey={refreshKey} />} />
|
||||
<Route path="/closed" element={<Closed search={search} refreshKey={refreshKey} />} />
|
||||
<Route path="/active" element={reports(<Active refreshKey={refreshKey} />)} />
|
||||
<Route path="/stats" element={reports(<Stats refreshKey={refreshKey} />)} />
|
||||
<Route path="/insights" element={reports(<Insights refreshKey={refreshKey} />)} />
|
||||
<Route path="/analytics" element={reports(<Overview refreshKey={refreshKey} />)} />
|
||||
<Route path="/kpi" element={reports(<SlaKpi refreshKey={refreshKey} />)} />
|
||||
<Route path="/admin" element={canUsers ? <Admin user={user} /> : <Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 30% 20%, #1e293b, var(--sidebar));
|
||||
padding: $s-4;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
background: var(--surface);
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: $s-8 $s-6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
justify-content: center;
|
||||
}
|
||||
.mark { font-size: 22px; }
|
||||
.name { font-size: 20px; font-weight: 700; letter-spacing: 0.16em; color: var(--text); }
|
||||
|
||||
.subtitle {
|
||||
margin: $s-2 0 $s-5;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.input {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: $s-4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: $s-3;
|
||||
padding: 8px 10px;
|
||||
border-radius: $radius;
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--primary-h); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { login, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './AuthWall.module.scss';
|
||||
|
||||
export default function AuthWall({ onLogin }: { onLogin: (user: CurrentUser) => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await login(username.trim(), password);
|
||||
if (res.success && res.user) onLogin(res.user);
|
||||
else setError(res.error || 'Invalid username or password');
|
||||
} catch {
|
||||
setError('Could not reach the server.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<form className={styles.card} onSubmit={submit}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.mark}>❄</span>
|
||||
<span className={styles.name}>FORGE 2.0</span>
|
||||
</div>
|
||||
<p className={styles.subtitle}>Sign in to view the ticket board.</p>
|
||||
|
||||
<label className={styles.label} htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
className={styles.input}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<label className={styles.label} htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button className={styles.button} type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--col);
|
||||
border-radius: $radius;
|
||||
padding: 9px 11px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition, transform $transition;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.number {
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--primary);
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
svg { opacity: 0.5; }
|
||||
&:hover { text-decoration: underline; svg { opacity: 1; } }
|
||||
}
|
||||
.topRight { display: flex; align-items: center; gap: 6px; }
|
||||
.jira { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; background: var(--primary-bg); color: var(--primary); }
|
||||
.ext { display: flex; color: var(--text-dim); &:hover { color: var(--primary); } }
|
||||
|
||||
.title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bm {
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.assignee { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.avatar {
|
||||
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--primary); color: #fff; font-size: 9px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.aName { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill {
|
||||
flex-shrink: 0; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 999px;
|
||||
background: var(--surface-alt); color: var(--text-muted); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex; gap: $s-3; font-size: 10px; color: var(--text-dim);
|
||||
border-top: 1px solid var(--border); padding-top: 5px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ExternalIcon } from '../../icons';
|
||||
import { parseDate, timeAgo, initials } from '../../../utils/format.utils';
|
||||
import type { Ticket } from '../../../types/ticket.types';
|
||||
import styles from './BoardCard.module.scss';
|
||||
|
||||
function daysSince(v: string | null): number | null {
|
||||
const d = parseDate(v);
|
||||
if (!d) return null;
|
||||
return Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
ticket: Ticket;
|
||||
color: string;
|
||||
brandColor?: string;
|
||||
onSelect: (t: Ticket) => void;
|
||||
}
|
||||
|
||||
export default function BoardCard({ ticket, color, brandColor, onSelect }: BoardCardProps) {
|
||||
const lifetime = daysSince(ticket.openedAt);
|
||||
const inState = daysSince(ticket.stateChangedAt);
|
||||
const bm = [ticket.brand, ticket.market].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ '--col': color } as React.CSSProperties} onClick={() => onSelect(ticket)}>
|
||||
<div className={styles.top}>
|
||||
{ticket.link ? (
|
||||
<a href={ticket.link} target="_blank" rel="noopener noreferrer" className={styles.number}
|
||||
onClick={e => e.stopPropagation()} title="Open in ServiceNow">
|
||||
{ticket.number}<ExternalIcon size={11} />
|
||||
</a>
|
||||
) : (
|
||||
<span className={styles.number}>{ticket.number}</span>
|
||||
)}
|
||||
{ticket.jira?.status && <span className={styles.jira}>{ticket.jira.status}</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.title} title={ticket.shortDesc}>{ticket.shortDesc || '—'}</div>
|
||||
|
||||
{bm && <span className={styles.bm} style={brandColor ? { background: `${brandColor}1a`, color: brandColor, borderColor: `${brandColor}55` } : undefined}>{bm}</span>}
|
||||
|
||||
<div className={styles.foot}>
|
||||
<span className={styles.assignee}>
|
||||
<span className={styles.avatar}>{initials(ticket.assignedTo)}</span>
|
||||
<span className={styles.aName}>{ticket.assignedTo ?? 'Unassigned'}</span>
|
||||
</span>
|
||||
{inState != null && <span className={styles.pill} title="days in current state">{inState}d</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
{lifetime != null && <span title="lifetime">⏱ {lifetime}d</span>}
|
||||
<span title="last activity">✎ {timeAgo(ticket.lastActivityAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: $s-4;
|
||||
margin-bottom: $s-5;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import styles from './PageHeader.module.scss';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, subtitle, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
{children && <div className={styles.actions}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
width: var(--sidebar-w);
|
||||
background: var(--sidebar);
|
||||
color: var(--sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $s-4 $s-3;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding: $s-2 $s-2 $s-5;
|
||||
}
|
||||
.brandMark {
|
||||
font-size: 20px;
|
||||
filter: drop-shadow(0 0 6px rgba(99, 102, 241, 0.6));
|
||||
}
|
||||
.brandName {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: #fff;
|
||||
}
|
||||
.brandVer {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav { flex: 1; }
|
||||
|
||||
.section {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--sidebar-dim);
|
||||
padding: 0 $s-2 $s-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
padding: 8px $s-2;
|
||||
border-radius: $radius;
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background $transition, color $transition;
|
||||
|
||||
&:hover { background: var(--sidebar-alt); color: #fff; }
|
||||
}
|
||||
.itemActive {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
&:hover { background: var(--primary-h); }
|
||||
}
|
||||
|
||||
.itemIcon { display: flex; opacity: 0.9; }
|
||||
.itemLabel { flex: 1; }
|
||||
|
||||
.badge {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: $s-4 $s-2 0;
|
||||
border-top: 1px solid var(--sidebar-alt);
|
||||
margin-top: $s-4;
|
||||
font-size: 11px;
|
||||
color: var(--sidebar-dim);
|
||||
}
|
||||
.version { color: var(--sidebar-dim); }
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { TicketIcon, ArchiveIcon, ChartIcon, KeyIcon } from '../icons';
|
||||
import { roleAtLeast, ROLE_LABELS, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './Sidebar.module.scss';
|
||||
|
||||
interface SidebarProps {
|
||||
activeCount: number;
|
||||
closedCount: number;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeCount, closedCount, user }: SidebarProps) {
|
||||
const canReports = roleAtLeast(user.role, 'pm');
|
||||
const canAdmin = roleAtLeast(user.role, 'lead');
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandMark}>❄</span>
|
||||
<span className={styles.brandName}>FORGE</span>
|
||||
<span className={styles.brandVer}>2.0</span>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<div className={styles.section}>Tickets</div>
|
||||
<NavItem to="/" label="Active board" icon={<TicketIcon />} count={activeCount} end />
|
||||
<NavItem to="/closed" label="Closed" icon={<ArchiveIcon />} count={closedCount} />
|
||||
|
||||
{canReports && <>
|
||||
<div className={styles.section} style={{ marginTop: 18 }}>Analytics</div>
|
||||
<NavItem to="/insights" label="PM Insights" icon={<ChartIcon />} />
|
||||
<NavItem to="/analytics" label="Overall stats" icon={<ChartIcon />} />
|
||||
<NavItem to="/active" label="Active stats" icon={<ChartIcon />} />
|
||||
<NavItem to="/kpi" label="PM KPIs — SLA" icon={<ChartIcon />} />
|
||||
</>}
|
||||
|
||||
{canAdmin && <>
|
||||
<div className={styles.section} style={{ marginTop: 18 }}>Admin</div>
|
||||
<NavItem to="/admin" label={user.role === 'admin' ? 'Users & Tokens' : 'Users'} icon={<KeyIcon />} />
|
||||
</>}
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span>{ROLE_LABELS[user.role]}</span>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
count?: number;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ to, label, icon, count, end }: NavItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => `${styles.item} ${isActive ? styles.itemActive : ''}`}
|
||||
>
|
||||
<span className={styles.itemIcon}>{icon}</span>
|
||||
<span className={styles.itemLabel}>{label}</span>
|
||||
{count !== undefined && count > 0 && <span className={styles.badge}>{count}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.progress { color: var(--primary); background: var(--primary-bg); border-color: rgba(99,102,241,0.25); }
|
||||
.waiting { color: var(--warning); background: var(--warning-bg); border-color: rgba(234,88,12,0.25); }
|
||||
.open { color: var(--success); background: var(--success-bg); border-color: rgba(22,163,74,0.25); }
|
||||
.done { color: var(--success); background: var(--success-bg); border-color: rgba(22,163,74,0.25); }
|
||||
.cancelled { color: var(--danger); background: var(--danger-bg); border-color: rgba(220,38,38,0.25); }
|
||||
.neutral { color: var(--neutral); background: var(--neutral-bg); border-color: rgba(100,116,139,0.25); }
|
||||
@@ -0,0 +1,16 @@
|
||||
import styles from './StatePill.module.scss';
|
||||
|
||||
// Map a ServiceNow state to a tone. Unknown states fall back to neutral.
|
||||
function tone(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s.includes('progress') || s === 'assigned') return styles.progress;
|
||||
if (s.includes('awaiting') || s.includes('hold')) return styles.waiting;
|
||||
if (s === 'open') return styles.open;
|
||||
if (s.includes('complete')) return styles.done;
|
||||
if (s.includes('cancel') || s.includes('incomplete')) return styles.cancelled;
|
||||
return styles.neutral;
|
||||
}
|
||||
|
||||
export default function StatePill({ state }: { state: string }) {
|
||||
return <span className={`${styles.pill} ${tone(state)}`}>{state || '—'}</span>;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
z-index: 100;
|
||||
animation: fade 0.12s ease;
|
||||
}
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 94vw;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: $s-5 $s-6;
|
||||
overflow-y: auto;
|
||||
animation: slide 0.16s ease;
|
||||
}
|
||||
@keyframes slide { from { transform: translateX(24px); opacity: 0.6; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $s-4;
|
||||
}
|
||||
.number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.ext {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
.title {
|
||||
margin-top: 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
}
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover { background: var(--border); color: var(--text); }
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
margin: $s-4 0;
|
||||
}
|
||||
.jiraBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3 $s-4;
|
||||
padding: $s-4 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field {
|
||||
dt { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
|
||||
dd { margin-top: 2px; font-size: 13px; color: var(--text); }
|
||||
}
|
||||
|
||||
.block { margin-top: $s-5; }
|
||||
.blockTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-3;
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.description, .comment {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.comment {
|
||||
padding: $s-3;
|
||||
background: var(--surface-alt);
|
||||
border-radius: $radius;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.timeline { list-style: none; position: relative; }
|
||||
.event {
|
||||
display: flex;
|
||||
gap: $s-3;
|
||||
padding-bottom: $s-4;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
.dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
.dotNote { background: var(--warning-bg); color: var(--warning); }
|
||||
.eventBody { flex: 1; }
|
||||
.eventTop { display: flex; align-items: center; gap: 8px; }
|
||||
.who { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.kind {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.when { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
|
||||
.more { font-size: 12px; color: var(--text-dim); padding-left: 36px; }
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from 'react';
|
||||
import StatePill from '../StatePill';
|
||||
import { CloseIcon, ExternalIcon, CommentIcon, NoteIcon } from '../icons';
|
||||
import { formatDate, formatDateTime, formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './TicketDetailModal.module.scss';
|
||||
|
||||
export default function TicketDetailModal({ ticket, onClose }: { ticket: Ticket; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const activity = [...(ticket.activity ?? [])].sort((a, b) => String(b.t).localeCompare(String(a.t)));
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<div className={styles.number}>
|
||||
{ticket.number}
|
||||
{ticket.link && (
|
||||
<a href={ticket.link} target="_blank" rel="noopener noreferrer" className={styles.ext}>
|
||||
Open in ServiceNow <ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<h2 className={styles.title}>{ticket.shortDesc || '—'}</h2>
|
||||
</div>
|
||||
<button className={styles.close} onClick={onClose} aria-label="Close"><CloseIcon /></button>
|
||||
</header>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<StatePill state={ticket.state} />
|
||||
{ticket.jira?.status && (ticket.jira.url
|
||||
? <a className={styles.jiraBadge} href={ticket.jira.url} target="_blank" rel="noopener noreferrer">
|
||||
Jira {ticket.jira.key ? `${ticket.jira.key} ` : ''}· {ticket.jira.status}
|
||||
</a>
|
||||
: <span className={styles.jiraBadge}>Jira: {ticket.jira.status}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className={styles.fields}>
|
||||
<Field label="Assigned to" value={ticket.assignedTo} />
|
||||
<Field label="Requested for" value={ticket.requestedFor} />
|
||||
<Field label="Group" value={ticket.assignmentGroup} />
|
||||
<Field label="Brand" value={ticket.brand} />
|
||||
<Field label="Market" value={ticket.market} />
|
||||
<Field label="Business unit" value={ticket.businessUnit} />
|
||||
<Field label="Opened" value={formatDate(ticket.openedAt)} />
|
||||
<Field label="Due" value={ticket.dueDate ? formatDate(ticket.dueDate) : null} />
|
||||
<Field label="State changed" value={formatDateTime(ticket.stateChangedAt)} />
|
||||
{ticket.status === 'closed' && <>
|
||||
<Field label="Fulfilled" value={formatDate(ticket.fulfillmentDate)} />
|
||||
<Field label="Time to first reply" value={formatMinutes(ticket.ttfrMinutes)} />
|
||||
<Field label="Client response" value={formatMinutes(ticket.clientRespMinutes)} />
|
||||
<Field label="Final cost" value={ticket.finalCost != null ? formatCost(ticket.finalCost) : null} />
|
||||
</>}
|
||||
</dl>
|
||||
|
||||
{ticket.description && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Description</h3>
|
||||
<p className={styles.description}>{ticket.description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ticket.lastComment && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Latest comment</h3>
|
||||
<p className={styles.comment}>{ticket.lastComment}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activity.length > 0 && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Activity <span className={styles.count}>{activity.length}</span></h3>
|
||||
<ul className={styles.timeline}>
|
||||
{activity.slice(0, 60).map((a, i) => (
|
||||
<li key={i} className={styles.event}>
|
||||
<span className={`${styles.dot} ${a.kind === 'worknote' ? styles.dotNote : ''}`}>
|
||||
{a.kind === 'worknote' ? <NoteIcon /> : <CommentIcon />}
|
||||
</span>
|
||||
<div className={styles.eventBody}>
|
||||
<div className={styles.eventTop}>
|
||||
<span className={styles.who}>{a.who}</span>
|
||||
<span className={styles.kind}>{a.kind}</span>
|
||||
</div>
|
||||
<div className={styles.when}>{formatDateTime(a.t)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{activity.length > 60 && <div className={styles.more}>+{activity.length - 60} earlier events</div>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
if (!value || value === '—') return null;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px $s-3;
|
||||
background: var(--surface-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.right { text-align: right; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
transition: background $transition;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: var(--row-hover); }
|
||||
|
||||
td { padding: 9px $s-3; vertical-align: middle; }
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.numLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary);
|
||||
|
||||
svg { opacity: 0.55; }
|
||||
&:hover { text-decoration: underline; svg { opacity: 1; } }
|
||||
}
|
||||
.jira {
|
||||
font-family: var(--font);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.assignee { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assigneeName {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-muted); white-space: nowrap; }
|
||||
.num { color: var(--text); }
|
||||
|
||||
.right {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.ago { font-size: 12px; }
|
||||
.ext {
|
||||
display: flex;
|
||||
color: var(--text-dim);
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import StatePill from '../StatePill';
|
||||
import { ExternalIcon } from '../icons';
|
||||
import { timeAgo, initials, formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './TicketTable.module.scss';
|
||||
|
||||
interface TicketTableProps {
|
||||
tickets: Ticket[];
|
||||
variant?: 'active' | 'closed';
|
||||
onSelect: (t: Ticket) => void;
|
||||
}
|
||||
|
||||
export default function TicketTable({ tickets, variant = 'active', onSelect }: TicketTableProps) {
|
||||
const closed = variant === 'closed';
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Short description</th>
|
||||
<th>State</th>
|
||||
<th>Assignee</th>
|
||||
{closed ? <th>Market</th> : <th>Group</th>}
|
||||
{closed ? <th className={styles.num}>TTFR</th> : <th>Brand</th>}
|
||||
{closed ? <th className={styles.num}>Cost</th> : <th className={styles.right}>Last activity</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map(t => (
|
||||
<tr key={t.number} onClick={() => onSelect(t)} className={styles.row}>
|
||||
<td className={styles.number}>
|
||||
{t.link ? (
|
||||
<a href={t.link} target="_blank" rel="noopener noreferrer" className={styles.numLink}
|
||||
onClick={e => e.stopPropagation()} title="Open in ServiceNow">
|
||||
{t.number}<ExternalIcon size={11} />
|
||||
</a>
|
||||
) : (
|
||||
<span>{t.number}</span>
|
||||
)}
|
||||
{t.jira?.status && <span className={styles.jira}>{t.jira.status}</span>}
|
||||
</td>
|
||||
<td className={styles.desc} title={t.shortDesc}>{t.shortDesc || '—'}</td>
|
||||
<td><StatePill state={t.state} /></td>
|
||||
<td>
|
||||
<span className={styles.assignee}>
|
||||
<span className={styles.avatar}>{initials(t.assignedTo)}</span>
|
||||
<span className={styles.assigneeName}>{t.assignedTo ?? '—'}</span>
|
||||
</span>
|
||||
</td>
|
||||
{closed ? (
|
||||
<td className={styles.muted}>{t.market ?? '—'}</td>
|
||||
) : (
|
||||
<td className={styles.muted} title={t.assignmentGroup ?? ''}>{shortGroup(t.assignmentGroup)}</td>
|
||||
)}
|
||||
{closed ? (
|
||||
<td className={styles.num}>{formatMinutes(t.ttfrMinutes)}</td>
|
||||
) : (
|
||||
<td className={styles.muted}>{t.brand ?? '—'}</td>
|
||||
)}
|
||||
{closed ? (
|
||||
<td className={styles.num}>{formatCost(t.finalCost)}</td>
|
||||
) : (
|
||||
<td className={styles.right}>
|
||||
<span className={styles.ago}>{timeAgo(t.lastActivityAt)}</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "L3 EU CORE RECKITT Marketing Web Presence" → "EU CORE"
|
||||
function shortGroup(g: string | null): string {
|
||||
if (!g) return '—';
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
height: var(--topbar-h);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-4;
|
||||
padding: 0 $s-5;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 460px;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px 0 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--bg);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color $transition, box-shadow $transition;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
background: var(--surface);
|
||||
}
|
||||
&::placeholder { color: var(--text-dim); }
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.lastSync { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.refresh {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background $transition, border-color $transition;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--surface-alt); border-color: var(--border-strong); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding-left: $s-3;
|
||||
margin-left: $s-1;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logout {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background $transition, color $transition;
|
||||
|
||||
&:hover { background: var(--surface-alt); color: var(--text); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SearchIcon } from '../icons';
|
||||
import { initials } from '../../utils/format.utils';
|
||||
import type { CurrentUser } from '../../services/auth.service';
|
||||
import styles from './TopBar.module.scss';
|
||||
|
||||
interface TopBarProps {
|
||||
search: string;
|
||||
onSearch: (v: string) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
lastSync: string | null;
|
||||
user: CurrentUser;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ search, onSearch, onRefresh, refreshing, lastSync, user, onLogout }: TopBarProps) {
|
||||
return (
|
||||
<header className={styles.topbar}>
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon}><SearchIcon /></span>
|
||||
<input
|
||||
className={styles.search}
|
||||
placeholder="Search tickets, assignees…"
|
||||
value={search}
|
||||
onChange={e => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
{lastSync && <span className={styles.lastSync}>Updated {lastSync}</span>}
|
||||
<button className={styles.refresh} onClick={onRefresh} disabled={refreshing}>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<div className={styles.user}>
|
||||
<span className={styles.avatar} title={user.username}>{initials(user.username)}</span>
|
||||
<button className={styles.logout} onClick={onLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.empty { color: var(--text-dim); font-size: 13px; padding: $s-4 0; }
|
||||
|
||||
.list { list-style: none; display: flex; flex-direction: column; gap: 9px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--lw) 1fr 4.5ch;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track { height: 18px; background: var(--surface-alt); border-radius: 4px; overflow: hidden; }
|
||||
.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--pct);
|
||||
border-radius: 4px;
|
||||
background: var(--swatch);
|
||||
transition: width 0.5s cubic-bezier(0.34, 1.2, 0.4, 1);
|
||||
}
|
||||
.value {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import styles from './BarList.module.scss';
|
||||
import type { Bucket } from '../../../types/analytics.types';
|
||||
|
||||
interface BarListProps {
|
||||
title: string;
|
||||
data: Bucket[];
|
||||
colorFor?: (b: Bucket, i: number) => string;
|
||||
format?: (n: number) => string;
|
||||
labelWidth?: number;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
export default function BarList({ title, data, colorFor, format, labelWidth = 150, empty = 'No data' }: BarListProps) {
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
{data.length === 0 ? (
|
||||
<div className={styles.empty}>{empty}</div>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{data.map((d, i) => (
|
||||
<li
|
||||
key={`${d.key}-${i}`}
|
||||
className={styles.row}
|
||||
style={{ '--pct': `${(d.count / max) * 100}%`, '--swatch': colorFor?.(d, i) ?? 'var(--primary)', '--lw': `${labelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<span className={styles.label} title={d.label}>{d.label}</span>
|
||||
<span className={styles.track}><span className={styles.fill} /></span>
|
||||
<span className={styles.value}>{format ? format(d.count) : d.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; gap: $s-3; margin-bottom: $s-4; }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); }
|
||||
.toggle {
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 24px; width: 30px; border: none; background: var(--surface); color: var(--text-muted); font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
.body { display: flex; gap: $s-5; align-items: center; flex-wrap: wrap; }
|
||||
.ring { width: 180px; height: 180px; flex-shrink: 0; }
|
||||
.centerNum { font-size: 30px; font-weight: 800; fill: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.centerLbl { font-size: 10px; fill: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.legend { list-style: none; flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 5px; max-height: 200px; overflow-y: auto; }
|
||||
.legItem { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.sw { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.legLabel { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legVal { font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './DonutChart.module.scss';
|
||||
import type { Bucket } from '../../../types/analytics.types';
|
||||
|
||||
const PALETTE = ['#6366f1', '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#3b82f6', '#14b8a6', '#f97316', '#a855f7', '#0ea5e9'];
|
||||
const SIZE = 200, R = 78, STROKE = 20, C = 2 * Math.PI * R;
|
||||
|
||||
export default function DonutChart({ title, data, topN = 11 }: { title: string; data: Bucket[]; topN?: number }) {
|
||||
const [pct, setPct] = useState(false);
|
||||
|
||||
const sorted = [...data].sort((a, b) => b.count - a.count);
|
||||
const head = sorted.slice(0, topN);
|
||||
const rest = sorted.slice(topN);
|
||||
const restTotal = rest.reduce((s, b) => s + b.count, 0);
|
||||
const slices = restTotal > 0 ? [...head, { key: '__others', label: `Others (${rest.length})`, count: restTotal }] : head;
|
||||
const total = slices.reduce((s, b) => s + b.count, 0) || 1;
|
||||
const color = (i: number) => (i === slices.length - 1 && restTotal > 0 ? 'var(--text-dim)' : PALETTE[i % PALETTE.length]);
|
||||
|
||||
let offset = 0;
|
||||
const arcs = slices.map((b, i) => {
|
||||
const frac = b.count / total;
|
||||
const dash = frac * C;
|
||||
const arc = { b, i, dash, gap: C - dash, rot: (offset / C) * 360, color: color(i) };
|
||||
offset += dash;
|
||||
return arc;
|
||||
});
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.toggle}>
|
||||
<button className={!pct ? styles.on : ''} onClick={() => setPct(false)}>#</button>
|
||||
<button className={pct ? styles.on : ''} onClick={() => setPct(true)}>%</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
<div className={styles.body}>
|
||||
<svg className={styles.ring} viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={title}>
|
||||
<circle cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none" stroke="var(--surface-alt)" strokeWidth={STROKE} />
|
||||
{arcs.map(a => (
|
||||
<circle key={a.b.key} cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none"
|
||||
stroke={a.color} strokeWidth={STROKE} strokeDasharray={`${a.dash} ${a.gap}`}
|
||||
transform={`rotate(${a.rot - 90} ${SIZE / 2} ${SIZE / 2})`}>
|
||||
<title>{`${a.b.label}: ${a.b.count} (${Math.round((a.b.count / total) * 100)}%)`}</title>
|
||||
</circle>
|
||||
))}
|
||||
<text x={SIZE / 2} y={SIZE / 2 - 4} textAnchor="middle" className={styles.centerNum}>{total}</text>
|
||||
<text x={SIZE / 2} y={SIZE / 2 + 14} textAnchor="middle" className={styles.centerLbl}>total</text>
|
||||
</svg>
|
||||
<ul className={styles.legend}>
|
||||
{arcs.map(a => (
|
||||
<li key={a.b.key} className={styles.legItem}>
|
||||
<span className={styles.sw} style={{ background: a.color }} />
|
||||
<span className={styles.legLabel} title={a.b.label}>{a.b.label}</span>
|
||||
<span className={styles.legVal}>{pct ? `${Math.round((a.b.count / total) * 100)}%` : a.b.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin-bottom: $s-4; }
|
||||
.list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--lw) 1fr 4.5ch;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
||||
}
|
||||
.label { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 5px; }
|
||||
.chev { font-size: 9px; color: var(--text-dim); transition: transform 0.15s ease; display: inline-block; }
|
||||
.chevOpen { transform: rotate(90deg); }
|
||||
.track { height: 18px; background: var(--surface-alt); border-radius: 4px; overflow: hidden; }
|
||||
.fill { display: block; height: 100%; width: var(--pct); border-radius: 4px; background: var(--swatch); transition: width 0.5s cubic-bezier(0.34, 1.2, 0.4, 1); }
|
||||
.value { text-align: right; font-size: 12px; font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.children { list-style: none; margin: 6px 0 4px 16px; display: flex; flex-direction: column; gap: 5px; padding-left: 8px; border-left: 2px solid var(--border); }
|
||||
.childRow { display: grid; grid-template-columns: var(--lw) 1fr 4.5ch; align-items: center; gap: $s-3; }
|
||||
.childLabel { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.childFill { display: block; height: 100%; width: var(--pct); border-radius: 4px; background: var(--text-dim); }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './ExpandBarList.module.scss';
|
||||
import type { NestedBucket, Bucket } from '../../../types/analytics.types';
|
||||
|
||||
interface ExpandBarListProps {
|
||||
title: string;
|
||||
data: NestedBucket[];
|
||||
colorFor?: (b: Bucket, i: number) => string;
|
||||
labelWidth?: number;
|
||||
}
|
||||
|
||||
export default function ExpandBarList({ title, data, colorFor, labelWidth = 150 }: ExpandBarListProps) {
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
const toggle = (k: string) => setOpen(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n; });
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
<ul className={styles.list}>
|
||||
{data.map((d, i) => {
|
||||
const isOpen = open.has(d.key);
|
||||
const childMax = Math.max(1, ...d.children.map(c => c.count));
|
||||
return (
|
||||
<li key={d.key}>
|
||||
<button
|
||||
className={styles.row}
|
||||
onClick={() => d.children.length && toggle(d.key)}
|
||||
style={{ '--pct': `${(d.count / max) * 100}%`, '--swatch': colorFor?.(d, i) ?? 'var(--primary)', '--lw': `${labelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<span className={styles.label} title={d.label}>
|
||||
{d.children.length > 0 && <span className={`${styles.chev} ${isOpen ? styles.chevOpen : ''}`}>▸</span>}
|
||||
{d.label}
|
||||
</span>
|
||||
<span className={styles.track}><span className={styles.fill} /></span>
|
||||
<span className={styles.value}>{d.count}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className={styles.children}>
|
||||
{d.children.map(c => (
|
||||
<li key={c.key} className={styles.childRow} style={{ '--pct': `${(c.count / childMax) * 100}%`, '--lw': `${labelWidth}px` } as React.CSSProperties}>
|
||||
<span className={styles.childLabel} title={c.label}>{c.label}</span>
|
||||
<span className={styles.track}><span className={styles.childFill} /></span>
|
||||
<span className={styles.value}>{c.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; gap: $s-3; margin-bottom: $s-2; }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); }
|
||||
.controls { display: flex; align-items: center; gap: $s-3; }
|
||||
.yoy { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
|
||||
.up { color: var(--success); background: var(--success-bg); }
|
||||
.down { color: var(--danger); background: var(--danger-bg); }
|
||||
.toggle {
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 26px; padding: 0 10px; border: none; background: var(--surface); color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
.legend { display: flex; gap: $s-4; flex-wrap: wrap; margin-bottom: $s-2; }
|
||||
.leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); b { color: var(--text); font-variant-numeric: tabular-nums; } }
|
||||
.sw { width: 12px; height: 8px; border-radius: 2px; }
|
||||
.svg { width: 100%; height: auto; display: block; }
|
||||
.axis { font-size: 9px; fill: var(--text-dim); font-family: var(--mono); }
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './GroupedBars.module.scss';
|
||||
import type { MonthPoint } from '../../../types/analytics.types';
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const YEAR_COLORS = ['#f97316', '#1a73e8', '#16a34a', '#dc2626', '#7c3aed'];
|
||||
const W = 700, H = 250, PADX = 40, PADT = 20, PADB = 42;
|
||||
|
||||
interface GroupedBarsProps {
|
||||
title: string;
|
||||
points: MonthPoint[]; // YYYY-MM
|
||||
format?: (n: number) => string;
|
||||
defaultMode?: 'bars' | 'line';
|
||||
}
|
||||
|
||||
export default function GroupedBars({ title, points, format, defaultMode = 'bars' }: GroupedBarsProps) {
|
||||
const [mode, setMode] = useState<'bars' | 'line'>(defaultMode);
|
||||
|
||||
const years = [...new Set(points.map(p => Number(p.month.slice(0, 4))))].sort();
|
||||
const yearColor = (y: number) => YEAR_COLORS[years.indexOf(y) % YEAR_COLORS.length];
|
||||
// grid[monthIdx][year] = count
|
||||
const grid: Record<number, Record<number, number>> = {};
|
||||
for (const p of points) {
|
||||
const y = Number(p.month.slice(0, 4));
|
||||
const mi = Number(p.month.slice(5, 7)) - 1;
|
||||
(grid[mi] ??= {})[y] = (grid[mi]?.[y] ?? 0) + p.count;
|
||||
}
|
||||
const max = Math.max(1, ...points.map(p => p.count));
|
||||
const totals = Object.fromEntries(years.map(y => [y, points.filter(p => p.month.startsWith(String(y))).reduce((s, p) => s + p.count, 0)]));
|
||||
const yoy = years.length >= 2 ? pctDelta(totals[years[years.length - 1]], totals[years[years.length - 2]]) : null;
|
||||
|
||||
const innerW = W - PADX * 2, innerH = H - PADT - PADB;
|
||||
const groupW = innerW / 12;
|
||||
const x = (mi: number) => PADX + mi * groupW;
|
||||
const y = (v: number) => PADT + (1 - v / max) * innerH;
|
||||
const tick = (v: number) => (format ? format(v) : String(v));
|
||||
|
||||
const linePath = (yr: number) => MONTHS
|
||||
.map((_, mi) => ({ mi, v: grid[mi]?.[yr] })).filter(p => p.v != null)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${(x(p.mi) + groupW / 2).toFixed(1)} ${y(p.v!).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.controls}>
|
||||
{yoy != null && <span className={`${styles.yoy} ${yoy >= 0 ? styles.up : styles.down}`}>{yoy >= 0 ? '▲' : '▼'} {Math.abs(yoy)}% YoY</span>}
|
||||
<div className={styles.toggle}>
|
||||
<button className={mode === 'bars' ? styles.on : ''} onClick={() => setMode('bars')}>Bars</button>
|
||||
<button className={mode === 'line' ? styles.on : ''} onClick={() => setMode('line')}>Line</button>
|
||||
</div>
|
||||
</div>
|
||||
</figcaption>
|
||||
<div className={styles.legend}>
|
||||
{years.map(yr => <span key={yr} className={styles.leg}><span className={styles.sw} style={{ background: yearColor(yr) }} />{yr} <b>{tick(totals[yr])}</b></span>)}
|
||||
</div>
|
||||
<svg className={styles.svg} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={title} preserveAspectRatio="xMidYMid meet">
|
||||
{[0, 0.5, 1].map(f => {
|
||||
const gy = PADT + f * innerH;
|
||||
return <g key={f}>
|
||||
<line x1={PADX} y1={gy} x2={W - PADX} y2={gy} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={6} y={gy + 3} className={styles.axis}>{tick(Math.round(max * (1 - f)))}</text>
|
||||
</g>;
|
||||
})}
|
||||
{mode === 'bars'
|
||||
? MONTHS.map((_, mi) => {
|
||||
const yrs = years.filter(yr => grid[mi]?.[yr] != null);
|
||||
const bw = (groupW * 0.7) / Math.max(1, yrs.length);
|
||||
return yrs.map((yr, i) => {
|
||||
const v = grid[mi]![yr];
|
||||
const bx = x(mi) + groupW * 0.15 + i * bw;
|
||||
return <rect key={`${mi}-${yr}`} x={bx} y={y(v)} width={Math.max(1, bw - 1)} height={PADT + innerH - y(v)} rx="1" fill={yearColor(yr)}><title>{`${MONTHS[mi]} ${yr}: ${tick(v)}`}</title></rect>;
|
||||
});
|
||||
})
|
||||
: years.map(yr => <path key={yr} d={linePath(yr)} fill="none" stroke={yearColor(yr)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />)}
|
||||
{MONTHS.map((m, mi) => <text key={m} x={x(mi) + groupW / 2} y={H - 22} textAnchor="middle" className={styles.axis}>{m}</text>)}
|
||||
</svg>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function pctDelta(cur: number, prev: number): number | null {
|
||||
if (!prev) return null;
|
||||
return Math.round(((cur - prev) / prev) * 100);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
border-top: 3px solid var(--primary);
|
||||
|
||||
&[data-tone='success'] { border-top-color: var(--success); }
|
||||
&[data-tone='warning'] { border-top-color: var(--warning); }
|
||||
&[data-tone='danger'] { border-top-color: var(--danger); }
|
||||
&[data-tone='neutral'] { border-top-color: var(--neutral); }
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
.label { margin-top: 8px; font-size: 13px; color: var(--text-muted); }
|
||||
.hint { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import styles from './KpiTile.module.scss';
|
||||
|
||||
interface KpiTileProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: 'primary' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export default function KpiTile({ label, value, tone = 'primary', hint }: KpiTileProps) {
|
||||
return (
|
||||
<div className={styles.tile} data-tone={tone}>
|
||||
<div className={styles.value}>{value}</div>
|
||||
<div className={styles.label}>{label}</div>
|
||||
{hint && <div className={styles.hint}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.head { display: flex; align-items: baseline; justify-content: space-between; gap: $s-3; margin-bottom: $s-4; }
|
||||
.title { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.sub { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.scroll { overflow-x: auto; }
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pmHead, .sizeHead, .totHead {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pmHead { text-align: left; }
|
||||
.sizeHead { display: table-cell; }
|
||||
.sizeChip {
|
||||
display: inline-block;
|
||||
min-width: 26px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.norm { display: block; margin-top: 2px; font-size: 9px; color: var(--text-dim); font-weight: 500; }
|
||||
|
||||
.pm {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: $s-2;
|
||||
}
|
||||
.cell, .total {
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
min-width: 52px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.total { background: var(--primary-bg); }
|
||||
.primary { display: block; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.meta { display: block; font-size: 10px; color: var(--text-muted); }
|
||||
.dot { color: var(--text-dim); }
|
||||
@@ -0,0 +1,82 @@
|
||||
import styles from './SlaHeatmap.module.scss';
|
||||
import type { SlaMetric, SlaCell } from '../../../types/analytics.types';
|
||||
|
||||
const SIZE_COLORS: Record<string, string> = {
|
||||
XS: '#14b8a6', S: '#1a73e8', M: '#f59e0b', L: '#10b981', XL: '#8b5cf6', XXL: '#ef4444',
|
||||
};
|
||||
|
||||
function normDays(norm: number | undefined, unit: 'days' | 'hours'): number | undefined {
|
||||
if (norm == null) return undefined;
|
||||
return unit === 'hours' ? norm / 24 : norm;
|
||||
}
|
||||
|
||||
// Value → background tint. Green when at/under norm, red when over, scaled.
|
||||
function cellTint(cell: SlaCell, norm: number | undefined): string | undefined {
|
||||
if (cell.avgDays == null || norm == null || norm <= 0) return undefined;
|
||||
const ratio = cell.avgDays / norm;
|
||||
if (ratio <= 1) {
|
||||
const t = Math.max(0.12, 1 - ratio); // deeper green the further under
|
||||
return `rgba(22, 163, 74, ${(0.10 + t * 0.22).toFixed(3)})`;
|
||||
}
|
||||
const over = Math.min(2, ratio - 1);
|
||||
return `rgba(220, 38, 38, ${(0.10 + over * 0.20).toFixed(3)})`;
|
||||
}
|
||||
|
||||
function fmt(cell: SlaCell, unit: 'days' | 'hours'): string {
|
||||
if (cell.avgDays == null) return '·';
|
||||
const v = unit === 'hours' ? cell.avgDays * 24 : cell.avgDays;
|
||||
return unit === 'hours' ? `${v.toFixed(1)}h` : `${v.toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export default function SlaHeatmap({ metric }: { metric: SlaMetric }) {
|
||||
const { title, unit, norms, pms, sizes, grid, totals } = metric;
|
||||
const isOtd = metric.key === 'otd';
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.sub}>{isOtd ? 'cell = on-time % · avg' : `avg ${unit} vs norm`}</span>
|
||||
</figcaption>
|
||||
<div className={styles.scroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.pmHead}>PM</th>
|
||||
{sizes.map(s => (
|
||||
<th key={s} className={styles.sizeHead}>
|
||||
<span className={styles.sizeChip} style={{ background: SIZE_COLORS[s] }}>{s}</span>
|
||||
{norms[s] != null && <span className={styles.norm}>≤{norms[s]}{unit === 'hours' ? 'h' : 'd'}</span>}
|
||||
</th>
|
||||
))}
|
||||
<th className={styles.totHead}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pms.map(pm => (
|
||||
<tr key={pm}>
|
||||
<td className={styles.pm} title={pm}>{pm}</td>
|
||||
{sizes.map(s => {
|
||||
const c = grid[pm]?.[s];
|
||||
const nd = normDays(norms[s], unit);
|
||||
if (!c || c.count === 0) return <td key={s} className={styles.cell}><span className={styles.dot}>·</span></td>;
|
||||
return (
|
||||
<td key={s} className={styles.cell} style={{ background: cellTint(c, nd) }}
|
||||
title={`${c.count} tickets · avg ${fmt(c, unit)}${c.onTimePct != null ? ` · ${c.onTimePct}% on-time` : ''}`}>
|
||||
<span className={styles.primary}>{isOtd ? (c.onTimePct != null ? `${c.onTimePct}%` : '·') : fmt(c, unit)}</span>
|
||||
<span className={styles.meta}>{isOtd ? fmt(c, unit) : `${c.count}`}</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={styles.total}>
|
||||
<span className={styles.primary}>{totals[pm]?.onTimePct != null ? `${totals[pm].onTimePct}%` : (totals[pm] ? fmt(totals[pm], unit) : '·')}</span>
|
||||
<span className={styles.meta}>{totals[pm]?.count ?? 0}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-3;
|
||||
}
|
||||
.legend { display: flex; gap: $s-4; margin-bottom: $s-2; flex-wrap: wrap; }
|
||||
.legItem { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
|
||||
.swatch { width: 12px; height: 3px; border-radius: 2px; }
|
||||
.svg { width: 100%; height: auto; display: block; }
|
||||
.axis { font-size: 9px; fill: var(--text-dim); font-family: var(--mono); }
|
||||
.empty { color: var(--text-dim); font-size: 13px; padding: $s-4 0; }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useId } from 'react';
|
||||
import styles from './TrendChart.module.scss';
|
||||
import type { MonthPoint } from '../../../types/analytics.types';
|
||||
|
||||
interface Series { label: string; color: string; points: MonthPoint[]; }
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
series: Series[];
|
||||
format?: (n: number) => string;
|
||||
area?: boolean;
|
||||
}
|
||||
|
||||
const W = 640, H = 220, PADX = 34, PADT = 16, PADB = 30;
|
||||
|
||||
export default function TrendChart({ title, series, format, area }: TrendChartProps) {
|
||||
const gid = useId().replace(/:/g, '');
|
||||
const months = [...new Set(series.flatMap(s => s.points.map(p => p.month)))].sort();
|
||||
const max = Math.max(1, ...series.flatMap(s => s.points.map(p => p.count)));
|
||||
const x = (i: number) => PADX + (months.length <= 1 ? 0 : (i / (months.length - 1)) * (W - PADX * 2));
|
||||
const y = (v: number) => PADT + (1 - v / max) * (H - PADT - PADB);
|
||||
|
||||
const line = (pts: MonthPoint[]) => {
|
||||
const byMonth = new Map(pts.map(p => [p.month, p.count]));
|
||||
return months.map((m, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(byMonth.get(m) ?? 0).toFixed(1)}`).join(' ');
|
||||
};
|
||||
|
||||
if (months.length < 2) {
|
||||
return <figure className={styles.card}><figcaption className={styles.title}>{title}</figcaption><div className={styles.empty}>Not enough data</div></figure>;
|
||||
}
|
||||
|
||||
const tick = (v: number) => (format ? format(v) : String(v));
|
||||
const labelEvery = Math.ceil(months.length / 8);
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
<div className={styles.legend}>
|
||||
{series.map(s => <span key={s.label} className={styles.legItem}><span className={styles.swatch} style={{ background: s.color }} />{s.label}</span>)}
|
||||
</div>
|
||||
<svg className={styles.svg} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={title} preserveAspectRatio="xMidYMid meet">
|
||||
{[0, 0.5, 1].map(f => {
|
||||
const gy = PADT + f * (H - PADT - PADB);
|
||||
return <g key={f}>
|
||||
<line x1={PADX} y1={gy} x2={W - PADX} y2={gy} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={4} y={gy + 3} className={styles.axis}>{tick(Math.round(max * (1 - f)))}</text>
|
||||
</g>;
|
||||
})}
|
||||
{area && series.length === 1 && (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id={`g${gid}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={series[0].color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={series[0].color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${line(series[0].points)} L ${x(months.length - 1)} ${y(0)} L ${x(0)} ${y(0)} Z`} fill={`url(#g${gid})`} />
|
||||
</>
|
||||
)}
|
||||
{series.map(s => <path key={s.label} d={line(s.points)} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />)}
|
||||
{months.map((m, i) => (i % labelEvery === 0
|
||||
? <text key={m} x={x(i)} y={H - 8} textAnchor="middle" className={styles.axis}>{m.slice(2)}</text>
|
||||
: null))}
|
||||
</svg>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
interface IconProps { size?: number; }
|
||||
|
||||
const base = (size: number) => ({
|
||||
width: size, height: size, viewBox: '0 0 24 24', fill: 'none',
|
||||
stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const,
|
||||
strokeLinejoin: 'round' as const,
|
||||
});
|
||||
|
||||
export function TicketIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M4 5h16a1 1 0 0 1 1 1v3a2 2 0 0 0 0 4v3a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-3a2 2 0 0 0 0-4V6a1 1 0 0 1 1-1Z" />
|
||||
<path d="M12 5v14" strokeDasharray="2 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M3 3v18h18" />
|
||||
<rect x="7" y="11" width="3" height="6" />
|
||||
<rect x="12" y="7" width="3" height="10" />
|
||||
<rect x="17" y="13" width="3" height="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ size = 18 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpreadsheetIcon({ size = 15 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Excel-brand icon: green rounded tile with the white "X" + a hint of grid.
|
||||
// Self-colored (own fills), so it reads as Excel regardless of button text colour.
|
||||
export function ExcelIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3.2" fill="#107C41" />
|
||||
<path d="M8.4 8.2 15.6 15.8M15.6 8.2 8.4 15.8" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="m10.7 12.3 9.3-9.3M17 5l2 2M15 7l1.5 1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M14 2v6h6M8 13h8M8 17h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/globals.scss';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: $s-3;
|
||||
}
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
@media (max-width: 1000px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.state, .stateError { padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import BarList from '../../components/charts/BarList';
|
||||
import TrendChart from '../../components/charts/TrendChart';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { getConfig, getJiraDurations } from '../../services/analytics.service';
|
||||
import { parseDate } from '../../utils/format.utils';
|
||||
import { BOARD_COLUMNS, boardColumn } from '../../utils/board';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import type { ForgeConfig, Bucket, MonthPoint, JiraDuration } from '../../types/analytics.types';
|
||||
import styles from './Active.module.scss';
|
||||
|
||||
const daysSince = (v: string | null): number | null => {
|
||||
const d = parseDate(v);
|
||||
return d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : null;
|
||||
};
|
||||
const median = (xs: number[]): number => {
|
||||
if (!xs.length) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return Math.round(s[Math.floor(s.length / 2)]);
|
||||
};
|
||||
|
||||
export default function Active({ refreshKey }: { refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [jiraDur, setJiraDur] = useState<JiraDuration[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
Promise.all([
|
||||
getTickets({ status: 'active' }),
|
||||
getConfig().catch(() => ({})),
|
||||
getJiraDurations().then(r => r.rows).catch(() => []),
|
||||
])
|
||||
.then(([t, c, jd]) => { if (alive) { setTickets(t); setCfg(c); setJiraDur(jd); } })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setReady(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
const colleagues = cfg.colleagues ?? [];
|
||||
|
||||
const model = useMemo(() => {
|
||||
const colOf = (t: Ticket) => boardColumn(t, colleagues);
|
||||
// by status (column)
|
||||
const byStatus: Bucket[] = BOARD_COLUMNS.map(c => ({
|
||||
key: c.key, label: c.label, count: tickets.filter(t => colOf(t) === c.key).length,
|
||||
})).filter(b => b.count > 0);
|
||||
// opened by month
|
||||
const om = new Map<string, number>();
|
||||
for (const t of tickets) {
|
||||
const d = parseDate(t.openedAt);
|
||||
if (d) { const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; om.set(k, (om.get(k) ?? 0) + 1); }
|
||||
}
|
||||
const openedByMonth: MonthPoint[] = [...om.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, count]) => ({ month, count }));
|
||||
// age histogram
|
||||
const AGE = [[0, 50], [50, 100], [100, 150], [150, 200], [200, Infinity]];
|
||||
const ageHist: Bucket[] = AGE.map(([lo, hi]) => ({
|
||||
key: `${lo}`, label: hi === Infinity ? `${lo}+ d` : `${lo}–${hi} d`,
|
||||
count: tickets.filter(t => { const a = daysSince(t.openedAt); return a != null && a >= lo && a < hi; }).length,
|
||||
}));
|
||||
// median days in current status per column
|
||||
const timeInStatus: Bucket[] = BOARD_COLUMNS.map(c => {
|
||||
const ds = tickets.filter(t => colOf(t) === c.key).map(t => daysSince(t.stateChangedAt)).filter((v): v is number => v != null);
|
||||
return { key: c.key, label: c.label, count: median(ds) };
|
||||
}).filter(b => b.count > 0);
|
||||
// by brand
|
||||
const bm = new Map<string, number>();
|
||||
for (const t of tickets) if (t.brand) bm.set(t.brand, (bm.get(t.brand) ?? 0) + 1);
|
||||
const byBrand: Bucket[] = [...bm.entries()].map(([k, count]) => ({ key: k, label: k, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
return { byStatus, openedByMonth, ageHist, timeInStatus, byBrand };
|
||||
}, [tickets, colleagues]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load active analytics.</div>;
|
||||
if (!ready) return <div className={styles.state}>Loading…</div>;
|
||||
|
||||
const colColor = (b: Bucket) => BOARD_COLUMNS.find(c => c.key === b.key)?.color ?? 'var(--primary)';
|
||||
const count = (key: string) => model.byStatus.find(b => b.key === key)?.count ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active statistics" subtitle={`${tickets.length} open tickets`} />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<KpiTile label="Open tickets" value={tickets.length} />
|
||||
<KpiTile label="Unassigned" value={count('unassigned')} tone="neutral" />
|
||||
<KpiTile label="In progress" value={count('wip')} tone="primary" />
|
||||
<KpiTile label="Customer replied" value={count('replied')} tone="danger" />
|
||||
<KpiTile label="Awaiting info" value={count('awaiting')} tone="warning" />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="By status" data={model.byStatus} colorFor={colColor} labelWidth={150} />
|
||||
<BarList title="Age distribution (days open)" data={model.ageHist} labelWidth={90}
|
||||
colorFor={(_, i) => ['#16a34a', '#22c55e', '#f59e0b', '#ea580c', '#dc2626'][i] ?? 'var(--primary)'} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<TrendChart title="Opened per month (still open)" area
|
||||
series={[{ label: 'Opened', color: 'var(--primary)', points: model.openedByMonth }]} />
|
||||
<BarList title="Median days in current status" data={model.timeInStatus} colorFor={colColor}
|
||||
format={n => `${n}d`} labelWidth={150} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="Active tickets by brand" data={model.byBrand} labelWidth={130} />
|
||||
{jiraDur.length > 0 && (
|
||||
<BarList
|
||||
title="Jira status durations (avg days)"
|
||||
data={jiraDur.map(d => ({ key: d.status, label: `${d.status} · ${d.count}`, count: d.avgDays }))}
|
||||
format={n => (n < 1 ? `${Math.round(n * 24)}h` : `${n}d`)}
|
||||
colorFor={(_, i) => `hsl(${(i * 37) % 360} 62% 55%)`}
|
||||
labelWidth={200}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
button {
|
||||
height: 36px; padding: 0 16px; border: none; background: transparent;
|
||||
color: var(--text-muted); font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
&.on { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
&:hover:not(.on) { color: var(--text); }
|
||||
}
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: 340px 1fr; gap: $s-4; align-items: start; @media (max-width: 900px) { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg;
|
||||
padding: $s-5; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cardTitle { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: $s-4; }
|
||||
.hint { font-size: 12px; color: var(--text-muted); margin-bottom: $s-3; margin-top: -8px; }
|
||||
|
||||
.label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 12px 0 5px; }
|
||||
.input {
|
||||
width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius;
|
||||
font-size: 13px; color: var(--text); background: var(--surface); outline: none;
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
.err { margin-top: 10px; padding: 8px 10px; border-radius: $radius; background: var(--danger-bg); color: var(--danger); font-size: 12px; }
|
||||
.btn {
|
||||
margin-top: 14px; width: 100%; height: 38px; border: none; border-radius: $radius;
|
||||
background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
&:hover:not(:disabled) { background: var(--primary-h); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.minted { margin-top: 14px; padding: 12px; border: 1px solid var(--success); border-radius: $radius; background: var(--success-bg); }
|
||||
.mintedLabel { font-size: 11px; font-weight: 700; color: var(--success); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; }
|
||||
.mintedToken { display: block; font-family: var(--mono); font-size: 11px; word-break: break-all; color: var(--text); cursor: pointer; background: var(--surface); padding: 8px; border-radius: $radius; }
|
||||
.copy { margin-top: 8px; height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
|
||||
.table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
}
|
||||
.muted { color: var(--text-muted); }
|
||||
.mono { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
|
||||
.right { text-align: right; }
|
||||
.roleBadge {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px;
|
||||
background: var(--surface-alt); color: var(--text-muted);
|
||||
&[data-role='admin'] { background: var(--danger-bg); color: var(--danger); }
|
||||
&[data-role='lead'] { background: var(--violet-bg, rgba(139,92,246,.1)); color: #7c3aed; }
|
||||
&[data-role='pm'] { background: var(--primary-bg); color: var(--primary); }
|
||||
}
|
||||
.danger { height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); color: var(--danger); font-size: 12px; font-weight: 600; cursor: pointer; &:hover { background: var(--danger-bg); } }
|
||||
.revoked { font-size: 12px; color: var(--text-dim); font-style: italic; }
|
||||
.revokedRow { opacity: 0.5; }
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { listUsers, createUser, deleteUser, listTokens, createToken, revokeToken, type AppUser, type TokenInfo } from '../../services/admin.service';
|
||||
import { ROLE_LABELS, roleAtLeast, type Role, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './Admin.module.scss';
|
||||
|
||||
const fmt = (v: string | null) => (v ? new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—');
|
||||
|
||||
export default function Admin({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === 'admin';
|
||||
const [tab, setTab] = useState<'users' | 'tokens'>('users');
|
||||
useEffect(() => { if (!isAdmin) setTab('users'); }, [isAdmin]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Administration" subtitle="Users and API access" />
|
||||
<div className={styles.tabs}>
|
||||
<button className={tab === 'users' ? styles.on : ''} onClick={() => setTab('users')}>Users</button>
|
||||
{isAdmin && <button className={tab === 'tokens' ? styles.on : ''} onClick={() => setTab('tokens')}>API Tokens</button>}
|
||||
</div>
|
||||
{tab === 'users' ? <UsersPanel me={user} /> : <TokensPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPanel({ me }: { me: CurrentUser }) {
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<Role>('viewer');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => { listUsers().then(setUsers).catch(() => setError('Failed to load users')); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Leadership can grant up to 'lead'; only admin can grant 'admin'.
|
||||
const grantable: Role[] = me.role === 'admin' ? ['viewer', 'pm', 'lead', 'admin'] : ['viewer', 'pm', 'lead'];
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await createUser(username.trim(), password, role);
|
||||
if (res.user) { setUsername(''); setPassword(''); setRole('viewer'); load(); }
|
||||
else setError(res.message || 'Could not create user');
|
||||
} catch { setError('Could not create user'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const remove = async (u: string) => {
|
||||
if (!confirm(`Delete user "${u}"?`)) return;
|
||||
await deleteUser(u).catch(() => {});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<form className={styles.card} onSubmit={submit}>
|
||||
<h3 className={styles.cardTitle}>Add user</h3>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input className={styles.input} value={username} onChange={e => setUsername(e.target.value)} autoComplete="off" />
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="new-password" placeholder="min 6 chars" />
|
||||
<label className={styles.label}>Role</label>
|
||||
<select className={styles.input} value={role} onChange={e => setRole(e.target.value as Role)}>
|
||||
{grantable.map(r => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
|
||||
</select>
|
||||
{error && <div className={styles.err}>{error}</div>}
|
||||
<button className={styles.btn} type="submit" disabled={busy || username.trim().length < 2 || password.length < 6}>
|
||||
{busy ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3 className={styles.cardTitle}>Users ({users.length})</h3>
|
||||
<table className={styles.table}>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Last login</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td><span className={styles.roleBadge} data-role={u.role}>{ROLE_LABELS[u.role]}</span></td>
|
||||
<td className={styles.muted}>{fmt(u.lastLoginAt)}</td>
|
||||
<td className={styles.right}>
|
||||
{u.username !== me.username && <button className={styles.danger} onClick={() => remove(u.username)}>Delete</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokensPanel() {
|
||||
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [minted, setMinted] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => { listTokens().then(setTokens).catch(() => {}); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await createToken(label.trim() || 'sync-extension');
|
||||
setMinted(res.token); setLabel(''); load();
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const revoke = async (id: number) => { if (confirm('Revoke this token?')) { await revokeToken(id).catch(() => {}); load(); } };
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<form className={styles.card} onSubmit={create}>
|
||||
<h3 className={styles.cardTitle}>Create API token</h3>
|
||||
<p className={styles.hint}>For the FORGE Snow Sync Chrome extension. The raw token is shown once.</p>
|
||||
<label className={styles.label}>Label</label>
|
||||
<input className={styles.input} value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. my laptop" />
|
||||
<button className={styles.btn} type="submit" disabled={busy}>{busy ? 'Creating…' : 'Create token'}</button>
|
||||
{minted && (
|
||||
<div className={styles.minted}>
|
||||
<div className={styles.mintedLabel}>Copy now — shown once:</div>
|
||||
<code className={styles.mintedToken} onClick={() => navigator.clipboard?.writeText(minted)}>{minted}</code>
|
||||
<button type="button" className={styles.copy} onClick={() => navigator.clipboard?.writeText(minted)}>Copy</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3 className={styles.cardTitle}>Tokens ({tokens.length})</h3>
|
||||
<table className={styles.table}>
|
||||
<thead><tr><th>Label</th><th>ID</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{tokens.map(t => (
|
||||
<tr key={t.id} className={t.revoked ? styles.revokedRow : ''}>
|
||||
<td>{t.label}</td>
|
||||
<td className={styles.mono}>{t.tokenId}</td>
|
||||
<td className={styles.muted}>{fmt(t.lastUsedAt)}</td>
|
||||
<td className={styles.muted}>{fmt(t.expiresAt)}</td>
|
||||
<td className={styles.right}>
|
||||
{t.revoked ? <span className={styles.revoked}>revoked</span> : <button className={styles.danger} onClick={() => revoke(t.id)}>Revoke</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { display: flex; flex-direction: column; gap: $s-4; height: 100%; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 26px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
button {
|
||||
height: 32px; padding: 0 12px; border: none; background: var(--surface);
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.export {
|
||||
height: 32px; padding: 0 12px; border: 1px solid var(--border); border-radius: $radius;
|
||||
background: var(--surface); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
&:hover:not(:disabled) { background: var(--surface-alt); border-color: var(--border-strong); }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
.exportIcon { display: inline-flex; color: #107c10; } // Excel green
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(240px, 1fr);
|
||||
gap: $s-3;
|
||||
overflow-x: auto;
|
||||
align-items: start;
|
||||
padding-bottom: $s-3;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
min-height: 120px;
|
||||
max-height: 100%;
|
||||
}
|
||||
.colHead {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--col);
|
||||
border-radius: $radius-lg $radius-lg 0 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.colTitleRow { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.colTitle { font-size: 12px; font-weight: 700; color: var(--text); }
|
||||
.colCount {
|
||||
min-width: 20px; text-align: center; font-size: 11px; font-weight: 700;
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--col); color: #fff;
|
||||
}
|
||||
.colSla { display: block; margin-top: 3px; font-size: 10px; color: var(--text-dim); }
|
||||
|
||||
.colBody { padding: 9px; display: flex; flex-direction: column; gap: 9px; overflow-y: auto; }
|
||||
.colEmpty { font-size: 12px; color: var(--text-dim); text-align: center; padding: $s-4 0; }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import BoardCard from '../../components/Board/BoardCard';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { getConfig } from '../../services/analytics.service';
|
||||
import { BOARD_COLUMNS, boardColumn, type ColumnKey } from '../../utils/board';
|
||||
import { exportTicketsXlsx } from '../../utils/excel.utils';
|
||||
import { ExcelIcon } from '../../components/icons';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import type { ForgeConfig } from '../../types/analytics.types';
|
||||
import styles from './Board.module.scss';
|
||||
|
||||
export default function Board({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [brand, setBrand] = useState('');
|
||||
const [market, setMarket] = useState('');
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const [view, setView] = useState<'board' | 'list'>('board');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true); setError(false);
|
||||
Promise.all([getTickets({ status: 'active', q: search || undefined }), getConfig().catch(() => ({}))])
|
||||
.then(([t, c]) => { if (alive) { setTickets(t); setCfg(c); } })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const colleagues = cfg.colleagues ?? [];
|
||||
const thresholds = cfg.insightsThresholds ?? {};
|
||||
|
||||
const distinct = (sel: (t: Ticket) => string | null) =>
|
||||
[...new Set(tickets.map(sel).filter((v): v is string => !!v))].sort();
|
||||
const brands = useMemo(() => distinct(t => t.brand), [tickets]);
|
||||
const markets = useMemo(() => distinct(t => t.market), [tickets]);
|
||||
const assignees = useMemo(() => distinct(t => t.assignedTo), [tickets]);
|
||||
|
||||
const filtered = useMemo(() => tickets.filter(t =>
|
||||
(!brand || t.brand === brand) && (!market || t.market === market) && (!assignee || t.assignedTo === assignee),
|
||||
), [tickets, brand, market, assignee]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const groups: Record<ColumnKey, Ticket[]> = { unassigned: [], open: [], hold: [], wip: [], replied: [], awaiting: [] };
|
||||
for (const t of filtered) groups[boardColumn(t, colleagues)].push(t);
|
||||
return groups;
|
||||
}, [filtered, colleagues]);
|
||||
|
||||
const brandColor = (b: string | null) => {
|
||||
if (!b) return undefined;
|
||||
return cfg.brandColors?.[b.toLowerCase().split(/[\s-]/)[0]];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active board" subtitle={`${filtered.length} of ${tickets.length} open tickets`}>
|
||||
<select className={styles.select} value={brand} onChange={e => setBrand(e.target.value)}>
|
||||
<option value="">All brands</option>{brands.map(b => <option key={b}>{b}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={market} onChange={e => setMarket(e.target.value)}>
|
||||
<option value="">All markets</option>{markets.map(m => <option key={m}>{m}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
<option value="">All PMs</option>{assignees.map(a => <option key={a}>{a}</option>)}
|
||||
</select>
|
||||
<div className={styles.toggle}>
|
||||
<button className={view === 'board' ? styles.on : ''} onClick={() => setView('board')}>Board</button>
|
||||
<button className={view === 'list' ? styles.on : ''} onClick={() => setView('list')}>List</button>
|
||||
</div>
|
||||
<button className={styles.export} onClick={() => exportTicketsXlsx(filtered, 'forge-active-tickets')} disabled={filtered.length === 0}
|
||||
title="Export the filtered tickets to Excel">
|
||||
<ExcelIcon size={15} />
|
||||
Excel
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{loading && <div className={styles.state}>Loading board…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load the board. Is the server running?</div>}
|
||||
|
||||
{!loading && !error && view === 'board' && (
|
||||
<div className={styles.board}>
|
||||
{BOARD_COLUMNS.map(col => {
|
||||
const items = columns[col.key];
|
||||
const thr = thresholds[col.slaKey];
|
||||
return (
|
||||
<section key={col.key} className={styles.column}>
|
||||
<header className={styles.colHead} style={{ '--col': col.color } as React.CSSProperties}>
|
||||
<div className={styles.colTitleRow}>
|
||||
<span className={styles.colTitle}>{col.label}</span>
|
||||
<span className={styles.colCount}>{items.length}</span>
|
||||
</div>
|
||||
{thr != null && <span className={styles.colSla}>{col.slaVerb(thr)}</span>}
|
||||
</header>
|
||||
<div className={styles.colBody}>
|
||||
{items.length === 0
|
||||
? <div className={styles.colEmpty}>No tickets</div>
|
||||
: items.map(t => <BoardCard key={t.number} ticket={t} color={col.color} brandColor={brandColor(t.brand)} onSelect={setSelected} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && view === 'list' && (
|
||||
<TicketTable tickets={filtered} variant="active" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-4;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.statValue { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.statLabel { margin-top: 2px; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './Closed.module.scss';
|
||||
|
||||
export default function Closed({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [brandFilter, setBrandFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
getTickets({ status: 'closed', q: search || undefined })
|
||||
.then(t => { if (alive) setTickets(t); })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const brands = useMemo(
|
||||
() => [...new Set(tickets.map(t => t.brand).filter((b): b is string => !!b))].sort(),
|
||||
[tickets],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => tickets.filter(t => !brandFilter || t.brand === brandFilter),
|
||||
[tickets, brandFilter],
|
||||
);
|
||||
|
||||
const totalCost = useMemo(
|
||||
() => filtered.reduce((sum, t) => sum + (t.finalCost ?? 0), 0),
|
||||
[filtered],
|
||||
);
|
||||
const avgTtfr = useMemo(() => {
|
||||
const vals = filtered.map(t => t.ttfrMinutes).filter((v): v is number => v != null);
|
||||
return vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : null;
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Closed tickets" subtitle={`${filtered.length} fulfilled requests`}>
|
||||
<select className={styles.select} value={brandFilter} onChange={e => setBrandFilter(e.target.value)}>
|
||||
<option value="">All brands</option>
|
||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</PageHeader>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className={styles.summary}>
|
||||
<Stat label="Tickets" value={String(filtered.length)} />
|
||||
<Stat label="Total cost" value={formatCost(totalCost)} />
|
||||
<Stat label="Avg. time to first reply" value={formatMinutes(avgTtfr)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className={styles.state}>Loading tickets…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load tickets. Is the server running?</div>}
|
||||
{!loading && !error && filtered.length === 0 && <div className={styles.state}>No closed tickets found.</div>}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TicketTable tickets={filtered} variant="closed" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './Dashboard.module.scss';
|
||||
|
||||
export default function Dashboard({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [stateFilter, setStateFilter] = useState<string>('');
|
||||
const [groupFilter, setGroupFilter] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
getTickets({ status: 'active', q: search || undefined })
|
||||
.then(t => { if (alive) setTickets(t); })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const states = useMemo(() => distinct(tickets.map(t => t.state)), [tickets]);
|
||||
const groups = useMemo(() => distinct(tickets.map(t => t.assignmentGroup)), [tickets]);
|
||||
|
||||
const filtered = useMemo(() => tickets.filter(t =>
|
||||
(!stateFilter || t.state === stateFilter) &&
|
||||
(!groupFilter || t.assignmentGroup === groupFilter),
|
||||
), [tickets, stateFilter, groupFilter]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active board" subtitle={`${filtered.length} of ${tickets.length} open tickets`}>
|
||||
<select className={styles.select} value={stateFilter} onChange={e => setStateFilter(e.target.value)}>
|
||||
<option value="">All states</option>
|
||||
{states.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={groupFilter} onChange={e => setGroupFilter(e.target.value)}>
|
||||
<option value="">All groups</option>
|
||||
{groups.map(g => <option key={g} value={g}>{shortGroup(g)}</option>)}
|
||||
</select>
|
||||
</PageHeader>
|
||||
|
||||
{loading && <div className={styles.state}>Loading tickets…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load tickets. Is the server running?</div>}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className={styles.state}>No tickets match the current filters.</div>
|
||||
)}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TicketTable tickets={filtered} variant="active" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function distinct(values: (string | null)[]): string[] {
|
||||
return [...new Set(values.filter((v): v is string => !!v))].sort();
|
||||
}
|
||||
function shortGroup(g: string): string {
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-3; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-2;
|
||||
}
|
||||
.h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
margin-top: $s-4;
|
||||
}
|
||||
|
||||
.section { display: flex; flex-direction: column; gap: $s-3; }
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
width: 100%;
|
||||
margin-top: $s-4;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
&:hover { color: var(--text); }
|
||||
}
|
||||
.chev {
|
||||
display: inline-flex;
|
||||
color: var(--text-dim);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.sectionHead[aria-expanded='true'] .chev { transform: rotate(90deg); }
|
||||
.sectionHead:hover .chev { color: var(--text-muted); }
|
||||
.sectionTitle { flex: 0 0 auto; }
|
||||
.sectionCount {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $s-4;
|
||||
}
|
||||
.grid[hidden] { display: none; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.cardHot { border-left: 3px solid var(--danger); }
|
||||
.cardHead {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $s-3;
|
||||
padding: $s-4 $s-5;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--surface-alt); }
|
||||
}
|
||||
.cardTitle { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.counts { display: flex; align-items: center; gap: 6px; }
|
||||
.problem {
|
||||
min-width: 22px; text-align: center; font-size: 11px; font-weight: 700;
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--danger-bg); color: var(--danger);
|
||||
}
|
||||
.total {
|
||||
min-width: 22px; text-align: center; font-size: 11px; font-weight: 700;
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--surface-alt); color: var(--text-muted);
|
||||
}
|
||||
|
||||
.list { list-style: none; border-top: 1px solid var(--border); max-height: 440px; overflow-y: auto; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding: $s-2 $s-5;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.emptyRow { padding: $s-3 $s-5; color: var(--text-dim); font-size: 12px; }
|
||||
.rowNum { display: inline-flex; align-items: center; gap: 3px; font-family: var(--mono); font-weight: 700; color: var(--primary); white-space: nowrap; }
|
||||
.rowDesc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.rowMeta { color: var(--text-muted); white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rowDays { font-weight: 600; color: var(--warning); font-variant-numeric: tabular-nums; }
|
||||
.rowCost { font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.pmCard {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pmTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
th {
|
||||
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: var(--text-muted); padding: 10px 14px;
|
||||
background: var(--surface-alt); border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
}
|
||||
td { padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
}
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.alertBadge { font-weight: 700; padding: 1px 8px; border-radius: 999px; background: var(--danger-bg); color: var(--danger); }
|
||||
.risk { font-weight: 600; color: var(--danger); }
|
||||
|
||||
.state, .stateError { padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import { ExternalIcon, ChevronIcon } from '../../components/icons';
|
||||
import { getInsights } from '../../services/analytics.service';
|
||||
import type { InsightsResponse, AlertGroup, InsightTicket } from '../../types/analytics.types';
|
||||
import styles from './Insights.module.scss';
|
||||
|
||||
const money = (n: number) => (n >= 1000 ? `£${(n / 1000).toFixed(1)}k` : `£${n}`);
|
||||
|
||||
// Which groups feed the alert total (vs informational).
|
||||
const ALERT_KEYS = new Set(['unassigned', 'assigned', 'hold', 'wipStalled', 'wipNoJira', 'replied', 'awaiting']);
|
||||
|
||||
export default function Insights({ refreshKey }: { refreshKey: number }) {
|
||||
const [data, setData] = useState<InsightsResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [sections, setSections] = useState({ alerts: true, watch: true });
|
||||
const toggleSection = (key: 'alerts' | 'watch') => setSections(s => ({ ...s, [key]: !s[key] }));
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
getInsights().then(d => { if (alive) setData(d); }).catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load insights.</div>;
|
||||
if (!data) return <div className={styles.state}>Analysing backlog…</div>;
|
||||
|
||||
const alertGroups = data.groups.filter(g => ALERT_KEYS.has(g.key));
|
||||
const infoGroups = data.groups.filter(g => !ALERT_KEYS.has(g.key));
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="PM Insights" subtitle="Problematic tickets across the active backlog, by category" />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<KpiTile label="Total alerts" value={data.totalAlerts} tone="danger" />
|
||||
<KpiTile label="Revenue at risk" value={money(data.revAtRisk)} tone="danger" />
|
||||
<KpiTile label="Waiting on PO" value={data.waitingPo.count} tone="warning" hint={money(data.waitingPo.revenue)} />
|
||||
<KpiTile label="PO escalations" value={`${data.waitingPo.levels.l1}·${data.waitingPo.levels.l2}·${data.waitingPo.levels.l3}`} tone="warning" hint="L1 · L2 · L3" />
|
||||
</div>
|
||||
|
||||
<Section title="Alert categories" count={alertGroups.length} open={sections.alerts} onToggle={() => toggleSection('alerts')}>
|
||||
{alertGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} showProblem />)}
|
||||
</Section>
|
||||
|
||||
<Section title="Watch list" count={infoGroups.length} open={sections.watch} onToggle={() => toggleSection('watch')}>
|
||||
{infoGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} />)}
|
||||
</Section>
|
||||
|
||||
<h2 className={styles.h2}>Per-PM roll-up</h2>
|
||||
<div className={styles.pmCard}>
|
||||
<table className={styles.pmTable}>
|
||||
<thead>
|
||||
<tr><th>PM</th><th className={styles.num}># Tickets</th><th className={styles.num}>$ Revenue</th><th className={styles.num}>⚠ Alerts</th><th className={styles.num}>Rev at risk</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.pms.map(p => (
|
||||
<tr key={p.pm}>
|
||||
<td>{p.pm}</td>
|
||||
<td className={styles.num}>{p.tickets}</td>
|
||||
<td className={styles.num}>{money(p.revenue)}</td>
|
||||
<td className={styles.num}>{p.alerts > 0 ? <span className={styles.alertBadge}>{p.alerts}</span> : '—'}</td>
|
||||
<td className={styles.num}>{p.revAtRisk > 0 ? <span className={styles.risk}>{money(p.revAtRisk)}</span> : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, count, open, onToggle, children }: { title: string; count: number; open: boolean; onToggle: () => void; children: ReactNode }) {
|
||||
const bodyId = `insights-${title.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<button className={styles.sectionHead} onClick={onToggle} aria-expanded={open} aria-controls={bodyId}>
|
||||
<span className={styles.chev}><ChevronIcon size={14} /></span>
|
||||
<span className={styles.sectionTitle}>{title}</span>
|
||||
<span className={styles.sectionCount}>{count}</span>
|
||||
</button>
|
||||
<div id={bodyId} className={styles.grid} hidden={!open}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertCard({ g, open, onToggle, showProblem }: { g: AlertGroup; open: boolean; onToggle: () => void; showProblem?: boolean }) {
|
||||
const shown = showProblem ? g.tickets.filter((_, i) => i < g.problematic || open) : g.tickets;
|
||||
return (
|
||||
<div className={`${styles.card} ${showProblem && g.problematic > 0 ? styles.cardHot : ''}`}>
|
||||
<button className={styles.cardHead} onClick={onToggle}>
|
||||
<span className={styles.cardTitle}>{g.label}</span>
|
||||
<span className={styles.counts}>
|
||||
{showProblem && <span className={styles.problem}>{g.problematic}</span>}
|
||||
<span className={styles.total}>{g.count}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className={styles.list}>
|
||||
{shown.length === 0 && <li className={styles.emptyRow}>No tickets</li>}
|
||||
{shown.map(t => <TicketRow key={t.number} t={t} />)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketRow({ t }: { t: InsightTicket }) {
|
||||
return (
|
||||
<li className={styles.row}>
|
||||
<a className={styles.rowNum} href={t.link ?? undefined} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>
|
||||
{t.number}{t.link && <ExternalIcon size={11} />}
|
||||
</a>
|
||||
<span className={styles.rowDesc} title={t.shortDesc}>{t.shortDesc}</span>
|
||||
<span className={styles.rowMeta}>{t.assignedTo ?? '—'}</span>
|
||||
{t.days != null && <span className={styles.rowDays}>{t.days}d</span>}
|
||||
{t.costGbp > 0 && <span className={styles.rowCost}>{money(t.costGbp)}</span>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: $s-3;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1000px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
.stack { display: flex; flex-direction: column; gap: $s-3; }
|
||||
|
||||
.revWrap { position: relative; }
|
||||
.revToggle {
|
||||
position: absolute; top: $s-5; right: 132px; z-index: 1;
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 26px; padding: 0 10px; border: none; background: var(--surface); color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import BarList from '../../components/charts/BarList';
|
||||
import GroupedBars from '../../components/charts/GroupedBars';
|
||||
import DonutChart from '../../components/charts/DonutChart';
|
||||
import ExpandBarList from '../../components/charts/ExpandBarList';
|
||||
import { getOverview, getConfig } from '../../services/analytics.service';
|
||||
import type { OverviewResponse, ForgeConfig, Bucket } from '../../types/analytics.types';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function money(n: number): string {
|
||||
if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `£${(n / 1_000).toFixed(0)}k`;
|
||||
return `£${n}`;
|
||||
}
|
||||
|
||||
export default function Overview({ refreshKey }: { refreshKey: number }) {
|
||||
const [data, setData] = useState<OverviewResponse | null>(null);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [error, setError] = useState(false);
|
||||
const [revMode, setRevMode] = useState<'cost' | 'tickets'>('cost');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
Promise.all([getOverview(), getConfig().catch(() => ({}))])
|
||||
.then(([o, c]) => { if (alive) { setData(o); setCfg(c); } })
|
||||
.catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load analytics.</div>;
|
||||
if (!data) return <div className={styles.state}>Loading analytics…</div>;
|
||||
|
||||
const brandColor = (b: Bucket) => cfg.brandColors?.[b.key.toLowerCase().split(/[\s-]/)[0]] ?? 'var(--primary)';
|
||||
const totalRevenue = data.revenueByMonth.reduce((s, m) => s + m.count, 0);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Overall statistics" subtitle={`${data.totals.total} tickets · ${data.totals.closed} closed`} />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<KpiTile label="Total tickets" value={data.totals.total} />
|
||||
<KpiTile label="Active" value={data.totals.active} tone="warning" />
|
||||
<KpiTile label="Closed" value={data.totals.closed} tone="success" />
|
||||
<KpiTile label="Delivered revenue" value={money(totalRevenue)} tone="primary" />
|
||||
<KpiTile label="Median lifetime" value={data.lifetime.medianDays != null ? `${data.lifetime.medianDays}d` : '—'} tone="neutral" hint={data.lifetime.avgDays != null ? `avg ${data.lifetime.avgDays}d` : undefined} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<GroupedBars title="Opened per month" points={data.openedByMonth} />
|
||||
<GroupedBars title="Closed per month" points={data.closedByMonth} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.revWrap}>
|
||||
<div className={styles.revToggle}>
|
||||
<button className={revMode === 'cost' ? styles.on : ''} onClick={() => setRevMode('cost')}>Cost</button>
|
||||
<button className={revMode === 'tickets' ? styles.on : ''} onClick={() => setRevMode('tickets')}>Tickets</button>
|
||||
</div>
|
||||
<GroupedBars
|
||||
title={revMode === 'cost' ? 'Revenue per month (closed)' : 'Tickets closed per month'}
|
||||
points={revMode === 'cost' ? data.revenueByMonth : data.closedByMonth}
|
||||
format={revMode === 'cost' ? money : undefined}
|
||||
defaultMode="line"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart title="Ticket share by requester" data={data.byRequesterShare} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<ExpandBarList title="By brand → market" data={data.byBrand} colorFor={brandColor} labelWidth={140} />
|
||||
<ExpandBarList title="By requester → brand" data={data.byRequester} labelWidth={200} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="By market" data={data.byMarket} labelWidth={130} />
|
||||
<div className={styles.stack}>
|
||||
<BarList title="By business unit" data={data.byBusinessUnit} labelWidth={130} />
|
||||
<BarList title="Ticket lifetime at close" data={data.lifetime.buckets} labelWidth={100}
|
||||
colorFor={(_, i) => ['#16a34a', '#22c55e', '#f59e0b', '#ea580c', '#dc2626', '#991b1b'][i] ?? 'var(--primary)'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
.grid { display: flex; flex-direction: column; gap: $s-3; }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import SlaHeatmap from '../../components/charts/SlaHeatmap';
|
||||
import { getSla } from '../../services/analytics.service';
|
||||
import type { SlaMetric } from '../../types/analytics.types';
|
||||
import styles from './SlaKpi.module.scss';
|
||||
|
||||
export default function SlaKpi({ refreshKey }: { refreshKey: number }) {
|
||||
const [metrics, setMetrics] = useState<SlaMetric[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
getSla()
|
||||
.then(r => { if (alive) setMetrics(r.metrics); })
|
||||
.catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load SLA metrics.</div>;
|
||||
if (!metrics) return <div className={styles.state}>Loading SLA metrics…</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="PM KPIs — SLA" subtitle="Per-PM × ticket-size averages vs configured norms (green = on target, red = over)" />
|
||||
<div className={styles.grid}>
|
||||
{metrics.map(m => <SlaHeatmap key={m.key} metric={m} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.kpi {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.kpiAccent {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-h));
|
||||
border-color: transparent;
|
||||
.kpiValue, .kpiLabel { color: #fff; }
|
||||
.kpiLabel { opacity: 0.85; }
|
||||
}
|
||||
.kpiValue { font-size: 30px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.kpiLabel { margin-top: 6px; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cardWide { grid-column: 1 / -1; }
|
||||
.cardTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
|
||||
.bars { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.barLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.barTrack {
|
||||
height: 20px;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.barFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-h));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.barValue {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { getStats } from '../../services/ticket.service';
|
||||
import type { StatsResponse } from '../../types/ticket.types';
|
||||
import styles from './Stats.module.scss';
|
||||
|
||||
export default function Stats({ refreshKey }: { refreshKey: number }) {
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
getStats().then(s => { if (alive) setStats(s); }).catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load statistics.</div>;
|
||||
if (!stats) return <div className={styles.state}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Statistics" subtitle="Distribution across the active ticket board" />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<Kpi label="Total tickets" value={stats.total} accent />
|
||||
<Kpi label="Active" value={stats.active} />
|
||||
<Kpi label="Closed" value={stats.closed} />
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<BarCard title="By state" data={stats.byState.map(d => ({ label: d.state, count: d.count }))} />
|
||||
<BarCard title="By assignment group" data={stats.byGroup.map(d => ({ label: shortGroup(d.group), count: d.count }))} />
|
||||
<BarCard title="Top assignees" data={stats.byAssignee.map(d => ({ label: d.assignee, count: d.count }))} wide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
<div className={`${styles.kpi} ${accent ? styles.kpiAccent : ''}`}>
|
||||
<div className={styles.kpiValue}>{value}</div>
|
||||
<div className={styles.kpiLabel}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarCard({ title, data, wide }: { title: string; data: { label: string; count: number }[]; wide?: boolean }) {
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
return (
|
||||
<div className={`${styles.card} ${wide ? styles.cardWide : ''}`}>
|
||||
<h3 className={styles.cardTitle}>{title}</h3>
|
||||
<ul className={styles.bars}>
|
||||
{data.map((d, i) => (
|
||||
<li key={`${d.label}-${i}`} className={styles.bar}>
|
||||
<span className={styles.barLabel} title={d.label}>{d.label}</span>
|
||||
<span className={styles.barTrack}>
|
||||
<span className={styles.barFill} style={{ width: `${(d.count / max) * 100}%` }} />
|
||||
</span>
|
||||
<span className={styles.barValue}>{d.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortGroup(g: string): string {
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { apiGet, apiPost, apiFetch } from './api.service';
|
||||
import type { Role } from './auth.service';
|
||||
|
||||
export interface AppUser { id: number; username: string; role: Role; createdAt: string | null; lastLoginAt: string | null; }
|
||||
export interface TokenInfo { id: number; tokenId: string; label: string; createdAt: string | null; lastUsedAt: string | null; expiresAt: string | null; revoked: boolean; }
|
||||
|
||||
export async function listUsers(): Promise<AppUser[]> {
|
||||
return (await apiGet<{ users: AppUser[] }>('/api/users')).users;
|
||||
}
|
||||
export function createUser(username: string, password: string, role: Role): Promise<{ user?: AppUser; error?: string; message?: string }> {
|
||||
return apiPost('/api/users', { username, password, role }, { allowAuthErrorBody: true });
|
||||
}
|
||||
export function deleteUser(username: string): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listTokens(): Promise<TokenInfo[]> {
|
||||
return (await apiGet<{ tokens: TokenInfo[] }>('/api/tokens')).tokens;
|
||||
}
|
||||
export function createToken(label: string): Promise<{ token: string; label: string }> {
|
||||
return apiPost('/api/tokens', { label });
|
||||
}
|
||||
export function revokeToken(id: number): Promise<{ success: boolean }> {
|
||||
return apiPost(`/api/tokens/${id}/revoke`, {});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiGet } from './api.service';
|
||||
import type { OverviewResponse, SlaMetric, ForgeConfig, InsightsResponse, JiraDurationsResponse } from '../types/analytics.types';
|
||||
|
||||
export function getInsights(): Promise<InsightsResponse> {
|
||||
return apiGet<InsightsResponse>('/api/insights');
|
||||
}
|
||||
|
||||
export function getJiraDurations(): Promise<JiraDurationsResponse> {
|
||||
return apiGet<JiraDurationsResponse>('/api/analytics/jira-durations');
|
||||
}
|
||||
|
||||
export function getOverview(): Promise<OverviewResponse> {
|
||||
return apiGet<OverviewResponse>('/api/analytics/overview');
|
||||
}
|
||||
|
||||
export function getSla(): Promise<{ metrics: SlaMetric[] }> {
|
||||
return apiGet<{ metrics: SlaMetric[] }>('/api/analytics/sla');
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<ForgeConfig> {
|
||||
return apiGet<ForgeConfig>('/api/config');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Fired when any API call returns 401 (session expired/absent). App registers it
|
||||
// to drop to the login screen.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
interface ApiOptions extends RequestInit {
|
||||
// When true, a 401 body is returned to the caller instead of throwing +
|
||||
// firing the logout hook. Used by /login to surface { success, error }.
|
||||
allowAuthErrorBody?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, options?: ApiOptions): Promise<T> {
|
||||
const { allowAuthErrorBody, ...init } = options ?? {};
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
if (!allowAuthErrorBody) { onUnauthorized?.(); throw new Error('unauthenticated'); }
|
||||
// else fall through and return the { success, error } body
|
||||
} else if (res.status === 400 || res.status === 409) {
|
||||
// Validation / conflict responses carry a { error, message } body the caller reads.
|
||||
} else {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function apiGet<T>(url: string): Promise<T> {
|
||||
return apiFetch<T>(url);
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, body: unknown, options?: ApiOptions): Promise<T> {
|
||||
return apiFetch<T>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiGet, apiPost } from './api.service';
|
||||
|
||||
export type Role = 'viewer' | 'pm' | 'lead' | 'admin';
|
||||
|
||||
export interface CurrentUser {
|
||||
username: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const ORDER: Role[] = ['viewer', 'pm', 'lead', 'admin'];
|
||||
export function roleAtLeast(role: Role | undefined, min: Role): boolean {
|
||||
return !!role && ORDER.indexOf(role) >= ORDER.indexOf(min);
|
||||
}
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
viewer: 'Viewer', pm: 'PM', lead: 'Project Leadership', admin: 'Admin',
|
||||
};
|
||||
|
||||
export async function getMe(): Promise<CurrentUser | null> {
|
||||
const data = await apiGet<{ user: CurrentUser | null }>('/api/me');
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<{ success: boolean; user?: CurrentUser; error?: string }> {
|
||||
return apiPost('/login', { username, password }, { allowAuthErrorBody: true });
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ success: boolean }> {
|
||||
return apiPost('/logout', {});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiGet } from './api.service';
|
||||
import type { Ticket, TicketFilters, StatsResponse } from '../types/ticket.types';
|
||||
|
||||
export async function getTickets(filters: TicketFilters = {}): Promise<Ticket[]> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(filters)) if (v) qs.set(k, String(v));
|
||||
const data = await apiGet<{ tickets: Ticket[] }>(`/api/tickets?${qs.toString()}`);
|
||||
return data.tickets;
|
||||
}
|
||||
|
||||
export function getTicket(number: string): Promise<Ticket> {
|
||||
return apiGet<Ticket>(`/api/tickets/${encodeURIComponent(number)}`);
|
||||
}
|
||||
|
||||
export function getStats(): Promise<StatsResponse> {
|
||||
return apiGet<StatsResponse>('/api/stats');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SCSS build-time tokens for use inside .module.scss files.
|
||||
// Runtime CSS custom properties live in globals.scss.
|
||||
|
||||
$radius: 6px;
|
||||
$radius-lg: 10px;
|
||||
|
||||
$s-1: 4px;
|
||||
$s-2: 8px;
|
||||
$s-3: 12px;
|
||||
$s-4: 16px;
|
||||
$s-5: 20px;
|
||||
$s-6: 24px;
|
||||
$s-8: 32px;
|
||||
|
||||
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
$font-mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
|
||||
$transition: 0.15s ease;
|
||||
@@ -0,0 +1,68 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-h: #4f46e5;
|
||||
--primary-bg: rgba(99, 102, 241, 0.10);
|
||||
|
||||
--success: #16a34a;
|
||||
--success-bg: rgba(22, 163, 74, 0.10);
|
||||
--warning: #ea580c;
|
||||
--warning-bg: rgba(234, 88, 12, 0.10);
|
||||
--danger: #dc2626;
|
||||
--danger-bg: rgba(220, 38, 38, 0.10);
|
||||
--neutral: #64748b;
|
||||
--neutral-bg: rgba(100, 116, 139, 0.12);
|
||||
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--border-strong:#cbd5e1;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
|
||||
--row-hover: #f8fafc;
|
||||
|
||||
--sidebar: #0f172a;
|
||||
--sidebar-alt: #1e293b;
|
||||
--sidebar-text: #cbd5e1;
|
||||
--sidebar-dim: #64748b;
|
||||
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.22);
|
||||
|
||||
--topbar-h: 56px;
|
||||
--sidebar-w: 236px;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface Bucket { key: string; label: string; count: number; }
|
||||
export interface NestedBucket extends Bucket { children: Bucket[]; }
|
||||
export interface MonthPoint { month: string; count: number; }
|
||||
|
||||
export interface OverviewResponse {
|
||||
totals: { total: number; active: number; closed: number };
|
||||
openedByMonth: MonthPoint[];
|
||||
closedByMonth: MonthPoint[];
|
||||
revenueByMonth: MonthPoint[];
|
||||
byState: Bucket[];
|
||||
byBrand: NestedBucket[];
|
||||
byMarket: Bucket[];
|
||||
byBusinessUnit: Bucket[];
|
||||
byRequester: NestedBucket[];
|
||||
byRequesterShare: Bucket[];
|
||||
lifetime: { buckets: Bucket[]; medianDays: number | null; avgDays: number | null; closed: number };
|
||||
}
|
||||
|
||||
export interface SlaCell { avgDays: number | null; count: number; onTime: number; onTimePct: number | null; }
|
||||
export interface SlaMetric {
|
||||
key: string;
|
||||
title: string;
|
||||
unit: 'days' | 'hours';
|
||||
norms: Record<string, number>;
|
||||
pms: string[];
|
||||
sizes: string[];
|
||||
grid: Record<string, Record<string, SlaCell>>;
|
||||
totals: Record<string, SlaCell>;
|
||||
}
|
||||
|
||||
export interface InsightTicket {
|
||||
number: string; shortDesc: string; assignedTo: string | null;
|
||||
brand: string | null; market: string | null; state: string;
|
||||
days: number | null; link: string | null; jiraStatus: string | null; costGbp: number;
|
||||
}
|
||||
export interface AlertGroup { key: string; label: string; count: number; problematic: number; tickets: InsightTicket[]; }
|
||||
export interface PmRow { pm: string; tickets: number; revenue: number; alerts: number; revAtRisk: number; }
|
||||
export interface InsightsResponse {
|
||||
totalAlerts: number;
|
||||
revAtRisk: number;
|
||||
groups: AlertGroup[];
|
||||
waitingPo: { count: number; revenue: number; levels: { l1: number; l2: number; l3: number } };
|
||||
pms: PmRow[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface JiraDuration { status: string; avgDays: number; avgHours: number; count: number; }
|
||||
export interface JiraDurationsResponse { order: string[]; rows: JiraDuration[]; }
|
||||
|
||||
export interface ForgeConfig {
|
||||
brandColors?: Record<string, string>;
|
||||
fxRates?: Record<string, number>;
|
||||
displayCurrency?: string;
|
||||
norms?: Record<string, Record<string, number>>;
|
||||
sizeThresholds?: Record<string, number>;
|
||||
insightsThresholds?: Record<string, number>;
|
||||
colleagues?: string[];
|
||||
latamAssignees?: string[];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type TicketStatus = 'active' | 'closed';
|
||||
|
||||
export interface ActivityEntry {
|
||||
kind: 'comment' | 'worknote' | string;
|
||||
t: string;
|
||||
who: string;
|
||||
}
|
||||
|
||||
export interface JiraInfo {
|
||||
status?: string;
|
||||
statusChangedAt?: string;
|
||||
key?: string | null;
|
||||
url?: string | null;
|
||||
assignee?: string | null;
|
||||
statusDurations?: Record<string, number>;
|
||||
movements?: { at: string; who?: string; from?: string; to?: string }[];
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
number: string;
|
||||
status: TicketStatus;
|
||||
state: string;
|
||||
shortDesc: string;
|
||||
assignedTo: string | null;
|
||||
assignmentGroup: string | null;
|
||||
brand: string | null;
|
||||
market: string | null;
|
||||
businessUnit: string | null;
|
||||
requestedFor: string | null;
|
||||
openedBy: string | null;
|
||||
openedAt: string | null;
|
||||
openedDate: string | null;
|
||||
closedDate: string | null;
|
||||
toDoAt: string | null;
|
||||
inUatAt: string | null;
|
||||
jiraKey: string | null;
|
||||
currencyCode: string | null;
|
||||
ticketYear: number | null;
|
||||
size: string | null;
|
||||
poNumber: string | null;
|
||||
invoiced: string | null;
|
||||
dueDate: string | null;
|
||||
stateChangedAt: string | null;
|
||||
stateChangedBy: string | null;
|
||||
lastActivityAt: string | null;
|
||||
lastActivityBy: string | null;
|
||||
lastComment: string | null;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
finalCost: number | null;
|
||||
ttfrMinutes: number | null;
|
||||
clientRespMinutes: number | null;
|
||||
fulfillmentDate: string | null;
|
||||
firstReplyAt: string | null;
|
||||
firstAssignedDate: string | null;
|
||||
updatedAt: string | null;
|
||||
jira: JiraInfo | null;
|
||||
activity: ActivityEntry[];
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: TicketStatus;
|
||||
state?: string;
|
||||
group?: string;
|
||||
assignee?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
total: number;
|
||||
active: number;
|
||||
closed: number;
|
||||
byState: { state: string; count: number }[];
|
||||
byGroup: { group: string; count: number }[];
|
||||
byAssignee: { assignee: string; count: number }[];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Ticket } from '../types/ticket.types';
|
||||
|
||||
// The initial app's kanban column model. A ticket's column is derived from its
|
||||
// ServiceNow state, with one pseudo-status: a Work-In-Progress ticket whose last
|
||||
// activity was by the client (not a colleague) becomes "Customer replied".
|
||||
|
||||
export type ColumnKey =
|
||||
| 'unassigned' | 'open' | 'hold' | 'wip' | 'replied' | 'awaiting';
|
||||
|
||||
export interface ColumnDef {
|
||||
key: ColumnKey;
|
||||
label: string;
|
||||
color: string;
|
||||
slaKey: string; // key into insightsThresholds
|
||||
slaVerb: (n: number) => string;
|
||||
}
|
||||
|
||||
export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'unassigned', label: 'Unassigned', color: '#78716c', slaKey: 'unassigned', slaVerb: n => `assign within ${n}d` },
|
||||
{ key: 'open', label: 'Open / Assigned', color: '#16a34a', slaKey: 'assigned', slaVerb: n => `start within ${n}d` },
|
||||
{ key: 'hold', label: 'On Hold', color: '#334155', slaKey: 'hold', slaVerb: n => `max ${n}d on hold` },
|
||||
{ key: 'wip', label: 'Work In Progress', color: '#6366f1', slaKey: 'wip', slaVerb: n => `max ${n}d in progress` },
|
||||
{ key: 'replied', label: 'Customer replied', color: '#dc2626', slaKey: 'customerReplied', slaVerb: n => `reply within ${n}d` },
|
||||
{ key: 'awaiting', label: 'Awaiting Customer Info', color: '#ea580c', slaKey: 'awaiting', slaVerb: n => `escalate after ${n}d` },
|
||||
];
|
||||
|
||||
const isColleague = (name: string | null, colleagues: string[]): boolean => {
|
||||
if (!name) return false;
|
||||
const n = name.trim().toLowerCase();
|
||||
return colleagues.some(c => c.trim().toLowerCase() === n);
|
||||
};
|
||||
|
||||
// A WIP ticket where the last touch was by the client (not us) → "Customer replied".
|
||||
function isAwaitingAgency(t: Ticket, colleagues: string[]): boolean {
|
||||
if (!/progress/i.test(t.state)) return false;
|
||||
if (colleagues.length > 0) return !isColleague(t.lastActivityBy, colleagues);
|
||||
// fallback: last activity by the requester
|
||||
return !!t.lastActivityBy && t.lastActivityBy === t.requestedFor;
|
||||
}
|
||||
|
||||
export function boardColumn(t: Ticket, colleagues: string[]): ColumnKey {
|
||||
const s = t.state.toLowerCase();
|
||||
if (/on.?hold/.test(s)) return 'hold';
|
||||
if (/progress/.test(s)) return isAwaitingAgency(t, colleagues) ? 'replied' : 'wip';
|
||||
if (/awaiting/.test(s)) return 'awaiting';
|
||||
if (!t.assignedTo) return 'unassigned';
|
||||
if (/open|new|assigned/.test(s)) return 'open';
|
||||
return 'open';
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import writeXlsxFile from 'write-excel-file';
|
||||
import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate';
|
||||
import type { Ticket } from '../types/ticket.types';
|
||||
import { parseDate } from './format.utils';
|
||||
|
||||
function daysSince(v: string | null): number | null {
|
||||
const d = parseDate(v);
|
||||
return d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : null;
|
||||
}
|
||||
// Short local date (avoids ugly raw SNOW timestamps).
|
||||
function shortDate(v: string | null): string {
|
||||
const d = parseDate(v);
|
||||
return d ? d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }) : '';
|
||||
}
|
||||
function xmlEsc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// --- palette (FORGE tokens) ------------------------------------------------
|
||||
const BORDER = '#E2E8F0';
|
||||
const HEADER_BG = '#6366F1'; // --primary indigo
|
||||
const STRIPE = '#F4F6FE'; // very light indigo — zebra rows
|
||||
const WHITE = '#FFFFFF';
|
||||
const LINK = '#2563EB';
|
||||
const ROW_H = 26;
|
||||
|
||||
type Row = Ticket & { __row: number };
|
||||
type Align = 'left' | 'right' | 'center';
|
||||
|
||||
interface Col {
|
||||
column: string;
|
||||
type: typeof String | typeof Number;
|
||||
width: number;
|
||||
align?: Align;
|
||||
wrap?: boolean;
|
||||
format?: string;
|
||||
color?: string;
|
||||
value: (t: Row) => string | number | null;
|
||||
}
|
||||
|
||||
const COLS: Col[] = [
|
||||
// Number is plain text (blue); a REAL Excel hyperlink is injected in post-processing
|
||||
// (a HYPERLINK() formula would break in `;`-separator Excel locales → #VALUE!).
|
||||
{ column: 'Number', type: String, width: 18, color: LINK, value: t => t.number },
|
||||
{ column: 'State', type: String, width: 22, value: t => t.state },
|
||||
{ column: 'Assignee', type: String, width: 24, value: t => t.assignedTo ?? '' },
|
||||
{ column: 'Short description', type: String, width: 54, wrap: true, value: t => t.shortDesc },
|
||||
{ column: 'Brand', type: String, width: 16, value: t => t.brand ?? '' },
|
||||
{ column: 'Market', type: String, width: 16, value: t => t.market ?? '' },
|
||||
{ column: 'Requester', type: String, width: 24, value: t => t.requestedFor ?? '' },
|
||||
{ column: 'Group', type: String, width: 32, value: t => t.assignmentGroup ?? '' },
|
||||
{ column: 'Lifetime (d)', type: Number, width: 13, align: 'right', value: t => daysSince(t.openedAt) },
|
||||
{ column: 'In state (d)', type: Number, width: 13, align: 'right', value: t => daysSince(t.stateChangedAt) },
|
||||
{ column: 'Last activity', type: String, width: 15, align: 'center', value: t => shortDate(t.lastActivityAt) },
|
||||
{ column: 'Jira', type: String, width: 15, value: t => t.jira?.status ?? '' },
|
||||
{ column: 'Jira key', type: String, width: 13, value: t => t.jiraKey ?? '' },
|
||||
{ column: 'Cost', type: Number, width: 12, align: 'right', format: '#,##0', value: t => t.finalCost },
|
||||
{ column: 'Ccy', type: String, width: 8, align: 'center', value: t => t.currencyCode ?? '' },
|
||||
{ column: 'PO', type: String, width: 16, value: t => t.poNumber ?? '' },
|
||||
];
|
||||
|
||||
function colLetter(n: number): string { // 1 → A, 27 → AA
|
||||
let s = '';
|
||||
while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = Math.floor((n - 1) / 26); }
|
||||
return s;
|
||||
}
|
||||
|
||||
// Post-process the .xlsx: add an <autoFilter> across the header and REAL Excel
|
||||
// hyperlinks on the Number column (column A), by editing the sheet XML + its
|
||||
// relationships and re-zipping. Locale-independent (no HYPERLINK formula).
|
||||
function finalizeXlsx(bytes: Uint8Array, data: Row[]): Uint8Array {
|
||||
const files = unzipSync(bytes);
|
||||
const sheet = 'xl/worksheets/sheet1.xml';
|
||||
const relsPath = 'xl/worksheets/_rels/sheet1.xml.rels';
|
||||
if (!files[sheet]) return bytes;
|
||||
|
||||
let xml = strFromU8(files[sheet]);
|
||||
let rels = files[relsPath]
|
||||
? strFromU8(files[relsPath])
|
||||
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
|
||||
|
||||
let maxId = 0;
|
||||
for (const m of rels.matchAll(/Id="rId(\d+)"/g)) maxId = Math.max(maxId, Number(m[1]));
|
||||
|
||||
const relEntries: string[] = [];
|
||||
const hlEntries: string[] = [];
|
||||
data.forEach((t, i) => {
|
||||
if (!t.link) return;
|
||||
const id = `rId${++maxId}`;
|
||||
relEntries.push(`<Relationship Id="${id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${xmlEsc(t.link)}" TargetMode="External"/>`);
|
||||
hlEntries.push(`<hyperlink ref="A${i + 2}" r:id="${id}"/>`); // row 1 = header
|
||||
});
|
||||
|
||||
const filterRef = `A1:${colLetter(COLS.length)}${data.length + 1}`;
|
||||
let inject = `<autoFilter ref="${filterRef}"/>`; // after </sheetData>
|
||||
if (hlEntries.length) inject += `<hyperlinks>${hlEntries.join('')}</hyperlinks>`; // after autoFilter
|
||||
if (!xml.includes('</sheetData>')) return bytes;
|
||||
xml = xml.replace('</sheetData>', `</sheetData>${inject}`);
|
||||
files[sheet] = strToU8(xml);
|
||||
|
||||
if (relEntries.length) {
|
||||
rels = rels.replace('</Relationships>', `${relEntries.join('')}</Relationships>`);
|
||||
files[relsPath] = strToU8(rels);
|
||||
}
|
||||
return zipSync(files);
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function datedName(base: string): string {
|
||||
const d = new Date();
|
||||
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return `${base}-${ymd}.xlsx`;
|
||||
}
|
||||
|
||||
// Export the given (already-filtered) tickets to a polished, filterable .xlsx with
|
||||
// clickable ticket numbers. `baseName` gets the date appended: base-YYYY-MM-DD.xlsx.
|
||||
export async function exportTicketsXlsx(tickets: Ticket[], baseName = 'forge-tickets'): Promise<void> {
|
||||
const data: Row[] = tickets.map((t, i) => ({ ...t, __row: i }));
|
||||
|
||||
const schema = COLS.map(c => ({
|
||||
column: c.column,
|
||||
type: c.type,
|
||||
value: c.value,
|
||||
width: c.width,
|
||||
format: c.format,
|
||||
getCellStyle: (r: Row) => ({
|
||||
align: c.align ?? 'left',
|
||||
alignVertical: 'center' as const,
|
||||
wrap: c.wrap ?? false,
|
||||
color: c.color,
|
||||
fontSize: 11,
|
||||
height: ROW_H,
|
||||
borderColor: BORDER,
|
||||
borderStyle: 'thin' as const,
|
||||
backgroundColor: r.__row % 2 === 1 ? STRIPE : WHITE,
|
||||
}),
|
||||
}));
|
||||
|
||||
// No fileName → Blob back, so we can post-process it.
|
||||
const blob = await writeXlsxFile(data, {
|
||||
schema,
|
||||
fontFamily: 'Calibri',
|
||||
fontSize: 11,
|
||||
headerStyle: {
|
||||
backgroundColor: HEADER_BG, color: '#FFFFFF', fontWeight: 'bold',
|
||||
align: 'center', alignVertical: 'center', height: 30,
|
||||
borderColor: HEADER_BG, borderStyle: 'thin',
|
||||
},
|
||||
stickyRowsCount: 1,
|
||||
}) as Blob;
|
||||
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const finalized = finalizeXlsx(bytes, data);
|
||||
let out = blob;
|
||||
if (finalized !== bytes) {
|
||||
const ab = finalized.buffer.slice(finalized.byteOffset, finalized.byteOffset + finalized.byteLength) as ArrayBuffer;
|
||||
out = new Blob([ab], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
triggerDownload(out, datedName(baseName));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDate, formatMinutes, formatCost, initials } from './format.utils';
|
||||
|
||||
describe('parseDate', () => {
|
||||
it('parses naive SNOW timestamps (space separator)', () => {
|
||||
expect(parseDate('2026-08-25 12:58:06')?.getFullYear()).toBe(2026);
|
||||
});
|
||||
it('parses ISO timestamps', () => {
|
||||
expect(parseDate('2024-09-16T05:50:16.000Z')?.getUTCMonth()).toBe(8);
|
||||
});
|
||||
it('returns null for empty/invalid', () => {
|
||||
expect(parseDate('')).toBeNull();
|
||||
expect(parseDate('nope')).toBeNull();
|
||||
expect(parseDate(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMinutes', () => {
|
||||
it('scales minutes → hours → days', () => {
|
||||
expect(formatMinutes(45)).toBe('45m');
|
||||
expect(formatMinutes(90)).toBe('1.5h');
|
||||
expect(formatMinutes(2880)).toBe('2.0d');
|
||||
expect(formatMinutes(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCost', () => {
|
||||
it('formats euros, dashes null', () => {
|
||||
expect(formatCost(150)).toBe('€150');
|
||||
expect(formatCost(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initials', () => {
|
||||
it('strips (Inactive) suffixes and takes first+last', () => {
|
||||
expect(initials('Alesya Prolagayeva')).toBe('AP');
|
||||
expect(initials('Levon Mkrtchyan (Inactive)')).toBe('LM');
|
||||
expect(initials(null)).toBe('?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Parse a ServiceNow timestamp. The archives mix naive local strings
|
||||
// ("2026-08-25 12:58:06") and ISO ("2024-09-16T05:50:16.000Z"); both parse
|
||||
// with a normalizing space→T for the naive form.
|
||||
export function parseDate(v: string | null | undefined): Date | null {
|
||||
if (typeof v !== 'string' || !v) return null;
|
||||
const iso = v.includes('T') ? v : v.replace(' ', 'T');
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatDate(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// Relative "3d ago" style label, coarse-grained.
|
||||
export function timeAgo(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
export function formatMinutes(mins: number | null): string {
|
||||
if (mins == null) return '—';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = mins / 60;
|
||||
if (hrs < 24) return `${hrs.toFixed(1)}h`;
|
||||
return `${(hrs / 24).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export function formatCost(v: number | null): string {
|
||||
if (v == null) return '—';
|
||||
return `€${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
export function initials(name: string | null | undefined): string {
|
||||
if (!name) return '?';
|
||||
const parts = name.replace(/\(.*?\)/g, '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return (parts[0][0] + (parts[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "WebWorker"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"outDir": "./node_modules/.tmp/tsnode",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, configDefaults } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const { version } = JSON.parse(
|
||||
readFileSync(new URL('./package.json', import.meta.url), 'utf-8'),
|
||||
) as { version: string };
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
css: {
|
||||
modules: { localsConvention: 'camelCaseOnly' },
|
||||
preprocessorOptions: { scss: { api: 'modern-compiler' } },
|
||||
},
|
||||
resolve: { alias: { '@': '/src' } },
|
||||
server: {
|
||||
proxy: {
|
||||
// FORGE server runs on 3100 (port 3000 is taken by the time-machine project).
|
||||
'/api': 'http://localhost:3100',
|
||||
'/healthz': 'http://localhost:3100',
|
||||
'/login': 'http://localhost:3100',
|
||||
'/logout': 'http://localhost:3100',
|
||||
},
|
||||
},
|
||||
build: { outDir: 'dist', emptyOutDir: true },
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts'],
|
||||
// Ignore macOS AppleDouble sidecars (`._*`). Syncing the tree to the NAS
|
||||
// over SMB/tar can split xattrs into `._foo.test.ts` files whose binary
|
||||
// content matches the test glob; esbuild then dies on the leading NUL byte.
|
||||
exclude: [...configDefaults.exclude, '**/._*'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# Compose v2 — `version:` key intentionally omitted (deprecated).
|
||||
#
|
||||
# Topology: a bundled Postgres (`db`, database `forge`) + the FORGE app. The
|
||||
# schema self-bootstraps in initDB() on app boot (no ORM, no migrations) and
|
||||
# seeds the bundled ticket archives on first run. App host port is 3099 (FORGE's
|
||||
# lane); behind the Synology reverse proxy this is what forge.mycloud.dp.ua maps to.
|
||||
|
||||
name: forge
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: forge
|
||||
POSTGRES_USER: forge
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-forge}
|
||||
volumes:
|
||||
- forge-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U forge -d forge']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: runtime
|
||||
image: forge-app:latest
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# Host 3089 -> container 3000. 3089 is FORGE's lane.
|
||||
- '3089:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgresql://forge:${POSTGRES_PASSWORD:-forge}@db:5432/forge
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# Read-API auth (see .env / .env.example).
|
||||
SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET in .env}
|
||||
AUTH_USER: ${AUTH_USER:-admin}
|
||||
AUTH_PASS: ${AUTH_PASS:?set AUTH_PASS in .env}
|
||||
# Guards POST /api/tokens. Prefer minting via the CLI instead (see SETUP.md).
|
||||
ADMIN_KEY: ${ADMIN_KEY:-}
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/healthz']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
mem_limit: 1g
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: '10m'
|
||||
max-file: '3'
|
||||
|
||||
volumes:
|
||||
forge-db:
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
# FORGE — setup & sync
|
||||
|
||||
## 1. Database
|
||||
|
||||
FORGE needs a Postgres database named `forge`. Either:
|
||||
|
||||
- **Bundled (compose):** `docker compose up -d db` starts `postgres:16-alpine`
|
||||
with database/user/password `forge` on a named volume. `DATABASE_URL` in
|
||||
`.env.example` already points at it.
|
||||
- **Your own Postgres:** create a `forge` database and set
|
||||
`DATABASE_URL=postgresql://USER:PASS@HOST:5432/forge` in `.env`.
|
||||
|
||||
The schema self-bootstraps on boot (`initDB()` in `server/db.ts`) — no migrations.
|
||||
On the very first boot, if the `tickets` table is empty, the bundled archives in
|
||||
`server/data/` are seeded (idempotent upsert).
|
||||
|
||||
## Seed data
|
||||
|
||||
The board ships pre-populated from `server/data/active_archive.json` +
|
||||
`closed_archive.json`, seeded on the first boot of an empty `tickets` table.
|
||||
Those archives are generated from a **Let it Snow `chrome.storage.local` dump**
|
||||
(the `sn_tickets`, `analytics_meta_cache`, and `jira_status_map` keys):
|
||||
|
||||
```bash
|
||||
# 1. drop the raw dump at repo root (gitignored — it contains real PII)
|
||||
# storage-dump.json
|
||||
node scripts/dump-to-archives.mjs # or: npm run dump-to-archives
|
||||
# → rewrites server/data/{active,closed}_archive.json
|
||||
|
||||
# 2. reload the DB from the regenerated archives (destructive: TRUNCATE + reseed)
|
||||
npm run reseed
|
||||
```
|
||||
|
||||
A ticket number can appear in both the active list and the closed cache; the
|
||||
loader applies closed first, then active, so the **live active board wins** over
|
||||
the stale closed snapshot. `npm run reseed` also works standalone whenever you
|
||||
want to reset the DB to the bundled archives.
|
||||
|
||||
> The raw `storage-dump.json` is a dev artifact and is gitignored. The derived
|
||||
> `server/data/*.json` still contain real ticket text and are baked into the
|
||||
> Docker image — keep the repo internal, or point the seed at a mounted volume.
|
||||
|
||||
## 2. Run
|
||||
|
||||
| | command | notes |
|
||||
|---|---|---|
|
||||
| Dev | `npm run dev` | server `:3000` + Vite client `:5173` (proxies `/api`) |
|
||||
| Build | `npm run build` | `tsc` + `client` production build |
|
||||
| Serve | `npm start` | serves the built client from the Express server |
|
||||
| Docker | `docker compose up -d --build` | app on host `:3099`, bundled DB |
|
||||
|
||||
## 3. Log in (read-API auth)
|
||||
|
||||
The whole read UI (`/api/tickets`, `/api/stats`, …) is gated behind a
|
||||
username/password login; `/healthz` and the token-authed `/api/sync` are not.
|
||||
|
||||
- Set `AUTH_USER` / `AUTH_PASS` and a `SESSION_SECRET` in `.env` (see
|
||||
`.env.example`). `SESSION_SECRET` is **required** in production.
|
||||
- On first boot the account is seeded into `app_users` with a **bcrypt** hash.
|
||||
Sessions are stored in Postgres (`user_sessions`, auto-created), so they
|
||||
survive restarts. Cookies are `httpOnly` + `secure` in production (HTTPS via
|
||||
the reverse proxy).
|
||||
- Changing `AUTH_PASS` later does **not** update an already-seeded account —
|
||||
delete the `app_users` row and reboot to re-seed, or update the hash directly.
|
||||
|
||||
The database role is least-privilege: `DATABASE_URL` uses a `forge_app` role
|
||||
that owns the app's tables but is **not** a Postgres superuser.
|
||||
|
||||
## 4. Mint a sync token
|
||||
|
||||
The Chrome extension authenticates with a bearer token (`fg_<id>_<secret>`,
|
||||
SHA-256 verifier stored server-side — the raw token is shown once).
|
||||
|
||||
**CLI (recommended):**
|
||||
|
||||
```bash
|
||||
npx tsx server/mint-token.ts "my laptop"
|
||||
```
|
||||
|
||||
**Over HTTP** (only if `ADMIN_KEY` is set in the environment):
|
||||
|
||||
```bash
|
||||
curl -X POST https://forge.mycloud.dp.ua/api/tokens \
|
||||
-H "x-admin-key: $ADMIN_KEY" -H 'content-type: application/json' \
|
||||
-d '{"label":"my laptop"}'
|
||||
```
|
||||
|
||||
Copy the `fg_…` value — it is not recoverable later.
|
||||
|
||||
## 5. Install the Chrome extension
|
||||
|
||||
1. `chrome://extensions` → enable **Developer mode** → **Load unpacked** →
|
||||
select the `extension/` folder.
|
||||
2. Open the extension's **options** (Server & token settings). Enter:
|
||||
- **Server URL** — e.g. `https://forge.mycloud.dp.ua` (or `http://localhost:3000`)
|
||||
- **Sync token** — the `fg_…` value from step 4
|
||||
- Click **Test connection** (hits `/healthz`), then **Save**. Chrome will ask
|
||||
to grant access to the server origin — accept it.
|
||||
3. Sign in to `https://rbassist.service-now.com` in the same browser.
|
||||
4. Click the extension toolbar icon → **Sync now**.
|
||||
|
||||
### What the sync does
|
||||
|
||||
The service worker runs a collector **in the ServiceNow page context**, so it
|
||||
reuses your live session cookie and CSRF token (`g_ck`). It pages the
|
||||
`sc_req_item` Table API for `active=true` tickets in *Marketing Web Presence*
|
||||
groups, maps them into FORGE's ticket shape, and `POST`s them to `/api/sync` in
|
||||
chunks of 100 with `Authorization: Bearer <token>`. The server upserts by ticket
|
||||
number, so re-syncing is safe and never duplicates.
|
||||
|
||||
## 6. Deploy to the Synology NAS
|
||||
|
||||
FORGE ships to the NAS as a docker-compose stack (bundled Postgres + app), fronted
|
||||
by the Synology reverse proxy (`forge.mycloud.dp.ua` → the published app port `3089`).
|
||||
Two scripts (ported from the Husky template):
|
||||
|
||||
- **`scripts/push-to-nas.sh`** (local, `npm run deploy`) — preflight (pinned SSH key,
|
||||
`IdentitiesOnly`) → test gate → rsync the tree to the NAS (excludes
|
||||
node_modules/dist/secrets/`storage-dump.json`) → run `deploy.sh` over SSH. Falls
|
||||
back to tar-over-ssh if macOS's openrsync is the only rsync.
|
||||
- **`scripts/deploy.sh`** (on the NAS) — Synology PATH+sudo handling → optional
|
||||
`git pull` (`--pull`) → `compose build` → `up -d --remove-orphans` → poll the app
|
||||
container until Docker reports **healthy**. `--fresh` tears down first but **never
|
||||
passes `-v`**, so the `forge-db` volume (your ticket data) is preserved.
|
||||
|
||||
**One-time, on the NAS:** create `${NAS_PATH}/.env` with the production secrets —
|
||||
`SESSION_SECRET`, `AUTH_USER`, `AUTH_PASS`, `POSTGRES_PASSWORD` (compose sets
|
||||
`DATABASE_URL`/`PORT`/`NODE_ENV` itself). See `.env.example`. Secrets live only on
|
||||
the NAS and are **never synced**.
|
||||
|
||||
**Config** is baked into `push-to-nas.sh` (defaults: `NAS_HOST=mycloud.dp.ua`,
|
||||
`NAS_USER=d.tkachenko`, `NAS_PORT=2323`, `NAS_PATH=/volume1/docker/forge`,
|
||||
`NAS_KEY=~/.ssh/id_ed25519`). Override via env vars or an optional `.deploy.env`
|
||||
(see `.deploy.env.example`). Key-based SSH to the NAS must already work.
|
||||
|
||||
```bash
|
||||
npm run deploy # test → sync → build → up → health
|
||||
npm run deploy -- --fresh # recreate the stack (db volume kept)
|
||||
npm run deploy -- --pull # git pull on the NAS first
|
||||
SKIP_TESTS=1 npm run deploy # emergency bypass of the local test gate
|
||||
```
|
||||
|
||||
The reverse-proxy mapping is configured once in DSM, not by the script.
|
||||
|
||||
## Security notes
|
||||
|
||||
- `GATSBY_`-style client exposure does not apply here, but the same rule holds:
|
||||
the API token and `g_ck` are **never logged**. Only the SHA-256 of the token
|
||||
secret is stored; `token_id` is a non-secret locator.
|
||||
- `POST /api/sync` is rate-limited (60 req/min) and rejects any request without a
|
||||
valid, unrevoked, unexpired token (401).
|
||||
- `server/data/*.json` contains **real ticket data** (names, comments). It ships
|
||||
with the repo for the demo seed — treat the repo as internal, or delete the
|
||||
archives and rely on live syncs only (the app runs fine with an empty seed).
|
||||
Binary file not shown.
@@ -0,0 +1,294 @@
|
||||
// FORGE Snow Sync — service worker.
|
||||
//
|
||||
// Flow, on a "Sync now" request from the popup:
|
||||
// 1. read { serverUrl, token } from chrome.storage.local
|
||||
// 2. find (or open) a rbassist.service-now.com tab
|
||||
// 3. executeScript a collector in the PAGE context — it pages the ServiceNow
|
||||
// Table API same-origin (so the session cookie + g_ck CSRF token just work)
|
||||
// and maps sc_req_item rows into FORGE's normalized ticket shape
|
||||
// 4. POST the tickets to <serverUrl>/api/sync in chunks, Authorization: Bearer <token>
|
||||
// 5. push a { state, ... } status to storage so the popup can render it
|
||||
//
|
||||
// The token and g_ck are never logged.
|
||||
|
||||
const SNOW_ORIGIN = 'https://rbassist.service-now.com';
|
||||
const CHUNK = 100;
|
||||
|
||||
function setStatus(status) {
|
||||
chrome.storage.local.set({ syncStatus: { ...status, at: Date.now() } });
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg?.type === 'SYNC') {
|
||||
runSync().then(sendResponse).catch(err => sendResponse({ ok: false, error: String(err?.message || err) }));
|
||||
return true; // async response
|
||||
}
|
||||
});
|
||||
|
||||
async function runSync() {
|
||||
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
|
||||
if (!serverUrl || !token) {
|
||||
const err = { ok: false, state: 'error', message: 'Set the server URL and token in options first.' };
|
||||
setStatus(err);
|
||||
return err;
|
||||
}
|
||||
|
||||
setStatus({ state: 'collecting', message: 'Reading ServiceNow…' });
|
||||
let tickets;
|
||||
try {
|
||||
tickets = await collectFromSnow();
|
||||
} catch (err) {
|
||||
const out = { ok: false, state: 'error', message: 'ServiceNow read failed: ' + (err?.message || err) };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!tickets.length) {
|
||||
const out = { ok: true, state: 'done', message: 'No active tickets found.', count: 0 };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
setStatus({ state: 'pushing', message: `Pushing ${tickets.length} tickets…` });
|
||||
let upserted = 0;
|
||||
try {
|
||||
for (let i = 0; i < tickets.length; i += CHUNK) {
|
||||
const chunk = tickets.slice(i, i + CHUNK);
|
||||
const res = await postChunk(serverUrl, token, chunk);
|
||||
upserted += res.upserted ?? chunk.length;
|
||||
setStatus({ state: 'pushing', message: `Pushed ${Math.min(i + CHUNK, tickets.length)}/${tickets.length}…` });
|
||||
}
|
||||
} catch (err) {
|
||||
const out = { ok: false, state: 'error', message: 'Push failed: ' + (err?.message || err) };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Jira phase (optional) — runs AFTER SNOW so it attaches to fresh rows ---
|
||||
let jiraMsg = '';
|
||||
if (jira && jira.baseUrl && jira.apiToken && Array.isArray(jira.boardIds) && jira.boardIds.length) {
|
||||
setStatus({ state: 'collecting', message: 'Reading Jira…' });
|
||||
try {
|
||||
const items = await collectFromJira(jira);
|
||||
if (items.length) {
|
||||
setStatus({ state: 'pushing', message: `Pushing ${items.length} Jira links…` });
|
||||
let matched = 0;
|
||||
for (let i = 0; i < items.length; i += CHUNK) {
|
||||
const res = await postJiraChunk(serverUrl, token, items.slice(i, i + CHUNK));
|
||||
matched += res.matched ?? 0;
|
||||
}
|
||||
jiraMsg = ` · ${matched} Jira`;
|
||||
} else {
|
||||
jiraMsg = ' · 0 Jira';
|
||||
}
|
||||
} catch (err) {
|
||||
// Jira is best-effort: a Jira failure must not fail the whole (successful) SNOW sync.
|
||||
jiraMsg = ' · Jira failed: ' + (err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const out = { ok: true, state: 'done', message: `Synced ${upserted} tickets${jiraMsg}.`, count: upserted };
|
||||
setStatus(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Jira collector (direct REST from the service worker) ------------------
|
||||
// Basic auth (email + token) for Jira Cloud; Bearer PAT for Jira Server/DC.
|
||||
function jiraAuthHeader(jira) {
|
||||
if (jira.email) return 'Basic ' + btoa(`${jira.email}:${jira.apiToken}`);
|
||||
return 'Bearer ' + jira.apiToken;
|
||||
}
|
||||
// Resolve the RITM number for a Jira issue: the custom field first, then a
|
||||
// regex over summary/description. Returns null when no RITM is linkable.
|
||||
function ritmForIssue(issue) {
|
||||
const f = issue.fields || {};
|
||||
const cf = f.customfield_26001;
|
||||
const cfStr = typeof cf === 'string' ? cf : (cf && (cf.value || cf.name)) || '';
|
||||
const m = String(cfStr || `${f.summary || ''} ${f.description || ''}`).match(/RITM\d+/i);
|
||||
return m ? m[0].toUpperCase() : null;
|
||||
}
|
||||
// From an issue's changelog, reconstruct time spent per status (ms) and the list
|
||||
// of status movements. Powers chart #17 (durations) + the movement charts.
|
||||
function jiraFromChangelog(issue) {
|
||||
const f = issue.fields || {};
|
||||
const histories = (issue.changelog && issue.changelog.histories) || [];
|
||||
// status-change events, ascending by time
|
||||
const events = [];
|
||||
for (const h of histories) {
|
||||
for (const it of (h.items || [])) {
|
||||
if (it.field === 'status') {
|
||||
events.push({ at: h.created, who: (h.author && (h.author.displayName || h.author.name)) || null, from: it.fromString, to: it.toString });
|
||||
}
|
||||
}
|
||||
}
|
||||
events.sort((a, b) => new Date(a.at) - new Date(b.at));
|
||||
const movements = events.map(e => ({ at: e.at, who: e.who, from: e.from, to: e.to }));
|
||||
|
||||
const durations = {};
|
||||
const add = (status, ms) => { if (status && ms > 0) durations[status] = (durations[status] || 0) + ms; };
|
||||
const created = f.created ? new Date(f.created).getTime() : (events[0] ? new Date(events[0].at).getTime() : Date.now());
|
||||
if (events.length) {
|
||||
// status held before the first transition
|
||||
add(events[0].from, new Date(events[0].at).getTime() - created);
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const start = new Date(events[i].at).getTime();
|
||||
const end = i + 1 < events.length ? new Date(events[i + 1].at).getTime() : Date.now();
|
||||
add(events[i].to, end - start);
|
||||
}
|
||||
} else if (f.status && f.status.name) {
|
||||
add(f.status.name, Date.now() - created); // never moved
|
||||
}
|
||||
return { statusDurations: durations, movements };
|
||||
}
|
||||
|
||||
async function collectFromJira(jira) {
|
||||
const base = jira.baseUrl.replace(/\/+$/, '');
|
||||
const auth = jiraAuthHeader(jira);
|
||||
const fields = 'summary,status,assignee,updated,created,customfield_26001';
|
||||
const byRitm = new Map(); // RITM → jira info (last write wins; board order)
|
||||
for (const boardId of jira.boardIds) {
|
||||
let startAt = 0;
|
||||
for (let page = 0; page < 50; page++) { // hard cap
|
||||
const url = `${base}/rest/agile/1.0/board/${encodeURIComponent(boardId)}/issue`
|
||||
+ `?fields=${encodeURIComponent(fields)}&expand=changelog&maxResults=50&startAt=${startAt}`;
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json', Authorization: auth } });
|
||||
if (res.status === 401 || res.status === 403) throw new Error('Jira auth rejected (' + res.status + ')');
|
||||
if (!res.ok) throw new Error('Jira board ' + boardId + ' HTTP ' + res.status);
|
||||
const body = await res.json();
|
||||
const issues = body.issues || [];
|
||||
for (const issue of issues) {
|
||||
const ritm = ritmForIssue(issue);
|
||||
if (!ritm) continue;
|
||||
const f = issue.fields || {};
|
||||
const cl = jiraFromChangelog(issue); // { statusDurations, movements }
|
||||
byRitm.set(ritm, {
|
||||
number: ritm,
|
||||
jira: {
|
||||
key: issue.key,
|
||||
status: (f.status && f.status.name) || null,
|
||||
statusChangedAt: f.updated || null,
|
||||
assignee: (f.assignee && (f.assignee.displayName || f.assignee.name)) || null,
|
||||
url: `${base}/browse/${issue.key}`,
|
||||
statusDurations: cl.statusDurations,
|
||||
movements: cl.movements,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (startAt + issues.length >= (body.total ?? 0) || issues.length === 0) break;
|
||||
startAt += issues.length;
|
||||
}
|
||||
}
|
||||
return [...byRitm.values()];
|
||||
}
|
||||
async function postJiraChunk(serverUrl, token, items) {
|
||||
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync/jira`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
if (res.status === 401) throw new Error('FORGE token rejected (401)');
|
||||
if (!res.ok) throw new Error('FORGE /api/sync/jira HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Find an existing SNOW tab or create a background one, then run the collector.
|
||||
async function collectFromSnow() {
|
||||
let tabs = await chrome.tabs.query({ url: `${SNOW_ORIGIN}/*` });
|
||||
let created = null;
|
||||
if (!tabs.length) {
|
||||
created = await chrome.tabs.create({ url: `${SNOW_ORIGIN}/now/nav/ui`, active: false });
|
||||
await waitForComplete(created.id);
|
||||
tabs = [created];
|
||||
}
|
||||
try {
|
||||
const [{ result }] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tabs[0].id },
|
||||
world: 'MAIN',
|
||||
func: pageCollector,
|
||||
args: [SNOW_ORIGIN],
|
||||
});
|
||||
if (result?.error) throw new Error(result.error);
|
||||
return result?.tickets ?? [];
|
||||
} finally {
|
||||
if (created?.id) chrome.tabs.remove(created.id).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function waitForComplete(tabId) {
|
||||
return new Promise(resolve => {
|
||||
const listener = (id, info) => {
|
||||
if (id === tabId && info.status === 'complete') {
|
||||
chrome.tabs.onUpdated.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.tabs.onUpdated.addListener(listener);
|
||||
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// Runs in the ServiceNow page context. Pages the Table API and maps rows.
|
||||
// Must be fully self-contained (serialized into the page).
|
||||
async function pageCollector(origin) {
|
||||
try {
|
||||
const gck = window.g_ck || (window.g_user && window.g_user.g_ck) || '';
|
||||
const query = 'active=true^assignment_groupLIKEMarketing Web Presence^ORDERBYDESCsys_updated_on';
|
||||
const fields = [
|
||||
'number', 'short_description', 'state', 'assigned_to', 'assignment_group',
|
||||
'opened_at', 'sys_updated_on', 'due_date', 'sys_updated_by',
|
||||
].join(',');
|
||||
|
||||
const tickets = [];
|
||||
const pageSize = 100;
|
||||
for (let offset = 0; offset < 2000; offset += pageSize) {
|
||||
const url = `${origin}/api/now/table/sc_req_item`
|
||||
+ `?sysparm_query=${encodeURIComponent(query)}`
|
||||
+ `&sysparm_display_value=true`
|
||||
+ `&sysparm_exclude_reference_link=true`
|
||||
+ `&sysparm_fields=${encodeURIComponent(fields)}`
|
||||
+ `&sysparm_limit=${pageSize}&sysparm_offset=${offset}`;
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...(gck ? { 'X-UserToken': gck } : {}) },
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) return { error: 'Not signed in to ServiceNow.' };
|
||||
if (!res.ok) return { error: 'Table API HTTP ' + res.status };
|
||||
const body = await res.json();
|
||||
const rows = body.result || [];
|
||||
for (const r of rows) {
|
||||
tickets.push({
|
||||
number: r.number,
|
||||
status: 'active',
|
||||
state: r.state || '',
|
||||
shortDesc: r.short_description || '',
|
||||
assignedTo: r.assigned_to || null,
|
||||
assignmentGroup: r.assignment_group || null,
|
||||
openedAt: r.opened_at || null,
|
||||
dueDate: r.due_date || null,
|
||||
stateChangedBy: r.sys_updated_by || null,
|
||||
lastActivityAt: r.sys_updated_on || null,
|
||||
lastActivityBy: r.sys_updated_by || null,
|
||||
updatedAt: r.sys_updated_on || null,
|
||||
link: `${origin}/sc_req_item.do?sysparm_query=number=${encodeURIComponent(r.number)}`,
|
||||
activity: [],
|
||||
});
|
||||
}
|
||||
if (rows.length < pageSize) break;
|
||||
}
|
||||
return { tickets };
|
||||
} catch (e) {
|
||||
return { error: String(e && e.message ? e.message : e) };
|
||||
}
|
||||
}
|
||||
|
||||
async function postChunk(serverUrl, token, tickets) {
|
||||
const res = await fetch(`${serverUrl.replace(/\/+$/, '')}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ tickets }),
|
||||
});
|
||||
if (res.status === 401) throw new Error('token rejected (401)');
|
||||
if (res.status === 403) throw new Error('forbidden (403)');
|
||||
if (!res.ok) throw new Error('server HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 270 B |
Binary file not shown.
|
After Width: | Height: | Size: 602 B |
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "FORGE Sync",
|
||||
"description": "Sync ServiceNow RITM tickets (and optionally Jira status/links) into your FORGE portal.",
|
||||
"version": "1.0.0",
|
||||
"permissions": ["storage", "scripting"],
|
||||
"host_permissions": ["https://rbassist.service-now.com/*"],
|
||||
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "FORGE Snow Sync",
|
||||
"default_icon": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"icons": {
|
||||
"16": "icons/icon-16.png",
|
||||
"48": "icons/icon-48.png",
|
||||
"128": "icons/icon-128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>FORGE Snow Sync — Settings</title>
|
||||
<style>
|
||||
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { font: 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 40px 20px; }
|
||||
.card { max-width: 460px; margin: 0 auto; background: var(--sf); border: 1px solid var(--bd); border-radius: 12px; padding: 28px; box-shadow: 0 4px 12px rgba(15,23,42,.06); }
|
||||
h1 { font-size: 18px; margin-bottom: 4px; letter-spacing: .04em; }
|
||||
p.sub { color: var(--mut); font-size: 13px; margin-bottom: 22px; }
|
||||
label { display: block; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--mut); margin: 16px 0 6px; }
|
||||
input { width: 100%; height: 40px; padding: 0 12px; border: 1px solid var(--bd); border-radius: 8px; font-size: 14px; color: var(--tx); outline: none; }
|
||||
input:focus { border-color: var(--p); box-shadow: 0 0 0 3px rgba(99,102,241,.12); }
|
||||
.hint { font-size: 12px; color: var(--mut); margin-top: 6px; }
|
||||
.row { display: flex; gap: 10px; margin-top: 24px; }
|
||||
button { flex: 1; height: 40px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.save { background: var(--p); color: #fff; }
|
||||
.save:hover { background: var(--p-h); }
|
||||
.test { background: var(--sf); color: var(--tx); border: 1px solid var(--bd); }
|
||||
.test:hover { background: var(--bg); }
|
||||
.feedback { margin-top: 16px; padding: 10px 12px; border-radius: 8px; font-size: 13px; display: none; }
|
||||
.feedback[data-state="ok"] { display: block; background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||
.feedback[data-state="error"] { display: block; background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>❄ FORGE Snow Sync</h1>
|
||||
<p class="sub">Point the extension at your FORGE server and paste a sync token.</p>
|
||||
|
||||
<form id="form">
|
||||
<label for="server-url">Server URL</label>
|
||||
<input id="server-url" type="url" placeholder="https://forge.mycloud.dp.ua" autocomplete="off" />
|
||||
<div class="hint">Where FORGE is reachable. No trailing slash needed.</div>
|
||||
|
||||
<label for="api-token">Sync token</label>
|
||||
<input id="api-token" type="password" placeholder="fg_…" autocomplete="off" />
|
||||
<div class="hint">Mint one in FORGE → Admin → API Tokens (or <code>npx tsx server/mint-token.ts</code>).</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--bd);margin:24px 0 4px" />
|
||||
<p class="sub" style="margin:12px 0 0">Jira (optional) — syncs status/links onto matching RITM tickets.</p>
|
||||
|
||||
<label for="jira-url">Jira base URL</label>
|
||||
<input id="jira-url" type="url" placeholder="https://support.dataart.com" autocomplete="off" />
|
||||
<div class="hint">Your Jira site (Cloud or self-hosted). Issue links use <code><baseUrl>/browse/<KEY></code>.</div>
|
||||
|
||||
<label for="jira-email">Jira email <span style="text-transform:none;font-weight:400">(Cloud only — leave blank for a PAT)</span></label>
|
||||
<input id="jira-email" type="email" placeholder="you@company.com" autocomplete="off" />
|
||||
|
||||
<label for="jira-token">Jira API token / PAT</label>
|
||||
<input id="jira-token" type="password" placeholder="ATATT… or a Personal Access Token" autocomplete="off" />
|
||||
<div class="hint">With an email → Basic auth (Jira Cloud). Without → Bearer PAT (Jira Server/DC).</div>
|
||||
|
||||
<label for="jira-boards">Board ID(s)</label>
|
||||
<input id="jira-boards" type="text" placeholder="13793" autocomplete="off" />
|
||||
<div class="hint">Comma-separated agile board ids to pull. Leave blank to skip Jira sync.</div>
|
||||
|
||||
<div class="row">
|
||||
<button type="button" class="test" id="test">Test connection</button>
|
||||
<button type="submit" class="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="feedback" id="feedback"></div>
|
||||
</div>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const urlInput = $('server-url');
|
||||
const tokenInput = $('api-token');
|
||||
const jUrl = $('jira-url');
|
||||
const jEmail = $('jira-email');
|
||||
const jToken = $('jira-token');
|
||||
const jBoards = $('jira-boards');
|
||||
const feedback = $('feedback');
|
||||
|
||||
const stripUrl = (v) => (v || '').trim().replace(/\/+$/, '');
|
||||
function originPattern(url) {
|
||||
try { return `${new URL(url).origin}/*`; } catch { return null; }
|
||||
}
|
||||
function show(state, text) {
|
||||
feedback.dataset.state = state;
|
||||
feedback.textContent = text;
|
||||
}
|
||||
function parseBoards(v) {
|
||||
return (v || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { serverUrl, token, jira } = await chrome.storage.local.get(['serverUrl', 'token', 'jira']);
|
||||
if (serverUrl) urlInput.value = serverUrl;
|
||||
if (token) tokenInput.value = token;
|
||||
if (jira) {
|
||||
if (jira.baseUrl) jUrl.value = jira.baseUrl;
|
||||
if (jira.email) jEmail.value = jira.email;
|
||||
if (jira.apiToken) jToken.value = jira.apiToken;
|
||||
if (Array.isArray(jira.boardIds)) jBoards.value = jira.boardIds.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePermission(pattern) {
|
||||
try { return await chrome.permissions.request({ origins: [pattern] }); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
const serverUrl = stripUrl(urlInput.value);
|
||||
const token = tokenInput.value.trim();
|
||||
const pattern = originPattern(serverUrl);
|
||||
if (!pattern) return show('error', 'Enter a valid server URL, e.g. https://forge.mycloud.dp.ua');
|
||||
if (!token) return show('error', 'Paste a sync token (starts with fg_).');
|
||||
if (!(await ensurePermission(pattern))) return show('error', 'Permission for the FORGE origin was denied.');
|
||||
|
||||
// Jira is optional; only persist + request permission when a base URL is given.
|
||||
const jiraBase = stripUrl(jUrl.value);
|
||||
let jira = null;
|
||||
if (jiraBase) {
|
||||
const jPattern = originPattern(jiraBase);
|
||||
if (!jPattern) return show('error', 'Enter a valid Jira base URL, e.g. https://support.dataart.com');
|
||||
if (!(await ensurePermission(jPattern))) return show('error', 'Permission for the Jira origin was denied.');
|
||||
jira = {
|
||||
baseUrl: jiraBase,
|
||||
email: jEmail.value.trim(),
|
||||
apiToken: jToken.value.trim(),
|
||||
boardIds: parseBoards(jBoards.value),
|
||||
jql: '',
|
||||
};
|
||||
}
|
||||
|
||||
await chrome.storage.local.set({ serverUrl, token, jira });
|
||||
show('ok', jira && jira.boardIds.length
|
||||
? 'Saved. Sync from the popup — SNOW then Jira.'
|
||||
: 'Saved. You can sync from the toolbar popup now.');
|
||||
}
|
||||
|
||||
async function test() {
|
||||
const serverUrl = stripUrl(urlInput.value);
|
||||
const pattern = originPattern(serverUrl);
|
||||
if (!pattern) return show('error', 'Enter a valid server URL first.');
|
||||
if (!(await ensurePermission(pattern))) return show('error', 'Permission for that origin was denied.');
|
||||
try {
|
||||
const res = await fetch(`${serverUrl}/healthz`);
|
||||
if (res.ok) show('ok', 'FORGE server reachable ✓');
|
||||
else show('error', `Server responded ${res.status}.`);
|
||||
} catch {
|
||||
show('error', 'Could not reach the server.');
|
||||
}
|
||||
}
|
||||
|
||||
$('form').addEventListener('submit', save);
|
||||
$('test').addEventListener('click', test);
|
||||
load();
|
||||
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<style>
|
||||
:root { --p: #6366f1; --p-h: #4f46e5; --bg: #f8fafc; --sf: #fff; --bd: #e2e8f0; --tx: #0f172a; --mut: #64748b; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { width: 300px; font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--tx); padding: 16px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.mark { font-size: 18px; }
|
||||
.name { font-weight: 700; letter-spacing: .12em; }
|
||||
button { width: 100%; height: 38px; border: none; border-radius: 8px; background: var(--p); color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: var(--p-h); }
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
.status { margin-top: 12px; padding: 10px; border-radius: 8px; background: var(--sf); border: 1px solid var(--bd); font-size: 12px; color: var(--mut); min-height: 38px; display: flex; align-items: center; gap: 8px; }
|
||||
.status[data-state="done"] { color: #16a34a; border-color: #bbf7d0; }
|
||||
.status[data-state="error"] { color: #dc2626; border-color: #fecaca; }
|
||||
.spin { width: 12px; height: 12px; border: 2px solid var(--bd); border-top-color: var(--p); border-radius: 50%; animation: s .7s linear infinite; }
|
||||
@keyframes s { to { transform: rotate(360deg); } }
|
||||
.opts { margin-top: 12px; text-align: center; }
|
||||
.opts a { color: var(--mut); font-size: 12px; text-decoration: none; }
|
||||
.opts a:hover { color: var(--p); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="head">
|
||||
<span class="mark">❄</span>
|
||||
<span class="name">FORGE SYNC</span>
|
||||
</div>
|
||||
<button id="sync">Sync now</button>
|
||||
<div class="status" id="status" data-state="idle">Ready.</div>
|
||||
<div class="opts"><a href="#" id="open-options">Server & token settings →</a></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const syncBtn = $('sync');
|
||||
const statusEl = $('status');
|
||||
|
||||
function render(status) {
|
||||
if (!status) { statusEl.dataset.state = 'idle'; statusEl.textContent = 'Ready.'; return; }
|
||||
statusEl.dataset.state = status.state || 'idle';
|
||||
const busy = status.state === 'collecting' || status.state === 'pushing';
|
||||
statusEl.innerHTML = busy ? '<span class="spin"></span>' : '';
|
||||
statusEl.append(status.message || '');
|
||||
syncBtn.disabled = busy;
|
||||
syncBtn.textContent = busy ? 'Syncing…' : 'Sync now';
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const { serverUrl, token, syncStatus } = await chrome.storage.local.get(['serverUrl', 'token', 'syncStatus']);
|
||||
if (!serverUrl || !token) {
|
||||
render({ state: 'error', message: 'Set server URL and token in settings first.' });
|
||||
} else {
|
||||
render(syncStatus);
|
||||
}
|
||||
}
|
||||
|
||||
syncBtn.addEventListener('click', async () => {
|
||||
render({ state: 'collecting', message: 'Starting…' });
|
||||
try {
|
||||
const res = await chrome.runtime.sendMessage({ type: 'SYNC' });
|
||||
render(res);
|
||||
} catch (err) {
|
||||
render({ state: 'error', message: String(err?.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
$('open-options').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
|
||||
// Live-update while a sync runs in the worker.
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === 'local' && changes.syncStatus) render(changes.syncStatus.newValue);
|
||||
});
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,368 @@
|
||||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { pool, initDB, upsertTickets, attachJira, type JiraAttach } from './server/db';
|
||||
import { listTickets, getTicket, getStats } from './server/tickets';
|
||||
import { resolveToken, createToken, listTokens, revokeToken } from './server/tokens';
|
||||
import { sessionMiddleware, requireAuth, requireRole, verifyLogin } from './server/auth';
|
||||
import { listUsers, createUser, deleteUser, isRole, getUserRole, countAdmins } from './server/db';
|
||||
import { getOverview, getSlaHeatmaps, getConfig, getJiraDurations } from './server/analytics';
|
||||
import { getInsights } from './server/insights';
|
||||
import type { Ticket, TicketStatus } from './server/types';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
// Prod: __dirname is /app/dist (compiled) → client/dist is a sibling ('..').
|
||||
// Dev (tsx index.ts): __dirname is the project root → client/dist is under it ('.').
|
||||
const CLIENT_DIST = path.join(__dirname, __dirname.endsWith('dist') ? '..' : '.', 'client', 'dist');
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error('FATAL: DATABASE_URL is required (points at the `forge` Postgres database)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', 1);
|
||||
app.use(express.json({ limit: '25mb' })); // sync payloads carry activity timelines
|
||||
app.use(sessionMiddleware());
|
||||
|
||||
// --- Health ----------------------------------------------------------------
|
||||
app.get('/healthz', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
res.json({ ok: true });
|
||||
} catch {
|
||||
res.status(503).json({ ok: false });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Auth ------------------------------------------------------------------
|
||||
const loginLimiter = rateLimit({ windowMs: 15 * 60_000, max: 20, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
app.post('/login', loginLimiter, async (req: Request, res: Response) => {
|
||||
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
||||
const ok = await verifyLogin(String(username ?? ''), String(password ?? ''));
|
||||
if (!ok) return res.status(401).json({ success: false, error: 'Invalid username or password' });
|
||||
// Regenerate the session id at the privilege transition (anti session-fixation).
|
||||
req.session.regenerate(err => {
|
||||
if (err) { console.error('session regenerate failed:', err.message); return res.status(500).json({ success: false, error: 'session_error' }); }
|
||||
req.session.user = ok;
|
||||
req.session.save(saveErr => {
|
||||
if (saveErr) { console.error('session save failed:', saveErr.message); return res.status(500).json({ success: false, error: 'session_error' }); }
|
||||
res.json({ success: true, user: ok });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/logout', (req: Request, res: Response) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('forge.sid');
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/me', (req: Request, res: Response) => {
|
||||
res.json({ user: req.session?.user ?? null });
|
||||
});
|
||||
|
||||
// --- Read API (auth-gated) -------------------------------------------------
|
||||
app.get('/api/tickets', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const q = req.query;
|
||||
const status = q.status === 'active' || q.status === 'closed' ? (q.status as TicketStatus) : undefined;
|
||||
const tickets = await listTickets({
|
||||
status,
|
||||
state: typeof q.state === 'string' ? q.state : undefined,
|
||||
group: typeof q.group === 'string' ? q.group : undefined,
|
||||
assignee: typeof q.assignee === 'string' ? q.assignee : undefined,
|
||||
q: typeof q.q === 'string' ? q.q : undefined,
|
||||
});
|
||||
res.json({ tickets, count: tickets.length });
|
||||
} catch (err) {
|
||||
console.error('GET /api/tickets', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/tickets/:number', requireAuth, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const t = await getTicket(String(req.params.number));
|
||||
if (!t) return res.status(404).json({ error: 'not_found' });
|
||||
res.json(t);
|
||||
} catch (err) {
|
||||
console.error('GET /api/tickets/:number', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats', requireAuth, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await getStats());
|
||||
} catch (err) {
|
||||
console.error('GET /api/stats', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/analytics/overview', requireRole('pm'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await getOverview());
|
||||
} catch (err) {
|
||||
console.error('GET /api/analytics/overview', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/analytics/sla', requireRole('pm'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ metrics: await getSlaHeatmaps() });
|
||||
} catch (err) {
|
||||
console.error('GET /api/analytics/sla', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/analytics/jira-durations', requireRole('pm'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await getJiraDurations());
|
||||
} catch (err) {
|
||||
console.error('GET /api/analytics/jira-durations', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/insights', requireRole('pm'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await getInsights());
|
||||
} catch (err) {
|
||||
console.error('GET /api/insights', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/config', requireAuth, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await getConfig());
|
||||
} catch (err) {
|
||||
console.error('GET /api/config', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Token-authed sync ingest (used by the Chrome extension) ---------------
|
||||
const syncLimiter = rateLimit({ windowMs: 60_000, max: 60, standardHeaders: true, legacyHeaders: false });
|
||||
|
||||
async function requireToken(req: Request, res: Response, next: NextFunction) {
|
||||
const principal = await resolveToken(req);
|
||||
if (!principal) return res.status(401).json({ error: 'token_invalid' });
|
||||
next();
|
||||
}
|
||||
|
||||
function s(v: unknown): string | null {
|
||||
if (v == null) return null;
|
||||
const str = String(v).trim();
|
||||
return str === '' ? null : str;
|
||||
}
|
||||
function n(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
const x = Number(v);
|
||||
return Number.isFinite(x) ? x : null;
|
||||
}
|
||||
|
||||
// Coerce an untrusted activity array into well-typed { kind, t, who } string
|
||||
// entries. Prevents a non-string `t` (which the client's date parser would call
|
||||
// .includes() on) from being persisted and later crashing the render.
|
||||
function sanitizeActivity(v: unknown): Ticket['activity'] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v
|
||||
.filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
|
||||
.map(e => ({ kind: String(e.kind ?? 'comment'), t: String(e.t ?? ''), who: String(e.who ?? '') }));
|
||||
}
|
||||
|
||||
// Coerce an untrusted incoming record into a normalized Ticket. Drops records
|
||||
// without a number. Unknown fields are ignored.
|
||||
function normalizeIncoming(raw: Record<string, unknown>): Ticket | null {
|
||||
const number = s(raw.number);
|
||||
if (!number) return null;
|
||||
const status: TicketStatus = raw.status === 'closed' ? 'closed' : 'active';
|
||||
return {
|
||||
number,
|
||||
status,
|
||||
state: String(raw.state ?? ''),
|
||||
shortDesc: String(raw.shortDesc ?? ''),
|
||||
assignedTo: s(raw.assignedTo),
|
||||
assignmentGroup: s(raw.assignmentGroup),
|
||||
brand: s(raw.brand),
|
||||
market: s(raw.market),
|
||||
businessUnit: s(raw.businessUnit),
|
||||
requestedFor: s(raw.requestedFor),
|
||||
openedBy: s(raw.openedBy),
|
||||
openedAt: s(raw.openedAt),
|
||||
openedDate: s(raw.openedDate) ?? s(raw.openedAt),
|
||||
closedDate: s(raw.closedDate),
|
||||
toDoAt: s(raw.toDoAt),
|
||||
inUatAt: s(raw.inUatAt),
|
||||
jiraKey: s(raw.jiraKey),
|
||||
currencyCode: s(raw.currencyCode),
|
||||
ticketYear: n(raw.ticketYear),
|
||||
size: s(raw.size),
|
||||
poNumber: s(raw.poNumber),
|
||||
invoiced: s(raw.invoiced),
|
||||
dueDate: s(raw.dueDate),
|
||||
stateChangedAt: s(raw.stateChangedAt),
|
||||
stateChangedBy: s(raw.stateChangedBy),
|
||||
lastActivityAt: s(raw.lastActivityAt),
|
||||
lastActivityBy: s(raw.lastActivityBy),
|
||||
lastComment: s(raw.lastComment),
|
||||
description: s(raw.description),
|
||||
link: s(raw.link),
|
||||
finalCost: n(raw.finalCost),
|
||||
ttfrMinutes: n(raw.ttfrMinutes),
|
||||
clientRespMinutes: n(raw.clientRespMinutes),
|
||||
fulfillmentDate: s(raw.fulfillmentDate),
|
||||
firstReplyAt: s(raw.firstReplyAt),
|
||||
firstAssignedDate: s(raw.firstAssignedDate),
|
||||
updatedAt: s(raw.updatedAt),
|
||||
jira: (raw.jira ?? null) as Ticket['jira'],
|
||||
activity: sanitizeActivity(raw.activity),
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/sync', syncLimiter, requireToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as { tickets?: unknown };
|
||||
if (!Array.isArray(body.tickets)) return res.status(400).json({ error: 'bad_payload' });
|
||||
const tickets = body.tickets
|
||||
.map(r => normalizeIncoming(r as Record<string, unknown>))
|
||||
.filter((t): t is Ticket => t !== null);
|
||||
if (tickets.length === 0) return res.json({ upserted: 0 });
|
||||
const upserted = await upsertTickets(tickets);
|
||||
res.json({ upserted });
|
||||
} catch (err) {
|
||||
console.error('POST /api/sync', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Jira enrichment ingest (from the extension's Jira REST collector). Attach-only:
|
||||
// updates only jira/jira_key on existing tickets, keyed by RITM number.
|
||||
app.post('/api/sync/jira', syncLimiter, requireToken, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = req.body as { items?: unknown };
|
||||
if (!Array.isArray(body.items)) return res.status(400).json({ error: 'bad_payload' });
|
||||
const items: JiraAttach[] = body.items
|
||||
.map(r => r as Record<string, unknown>)
|
||||
.filter(r => r && typeof r.number === 'string' && (r.number as string).trim())
|
||||
.map(r => ({
|
||||
number: String(r.number).trim(),
|
||||
jira: (r.jira && typeof r.jira === 'object' ? r.jira : {}) as Record<string, unknown>,
|
||||
jiraKey: typeof r.jiraKey === 'string' ? r.jiraKey : (typeof (r.jira as { key?: unknown })?.key === 'string' ? (r.jira as { key: string }).key : null),
|
||||
}));
|
||||
if (items.length === 0) return res.json({ matched: 0 });
|
||||
const matched = await attachJira(items);
|
||||
res.json({ matched, received: items.length });
|
||||
} catch (err) {
|
||||
console.error('POST /api/sync/jira', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- User management (Project Leadership + Admin) --------------------------
|
||||
app.get('/api/users', requireRole('lead'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ users: await listUsers() });
|
||||
} catch (err) {
|
||||
console.error('GET /api/users', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/users', requireRole('lead'), async (req: Request, res: Response) => {
|
||||
const { username, password, role } = (req.body ?? {}) as { username?: string; password?: string; role?: string };
|
||||
const u = String(username ?? '').trim();
|
||||
const p = String(password ?? '');
|
||||
if (u.length < 2 || p.length < 6) return res.status(400).json({ error: 'invalid', message: 'Username ≥2 and password ≥6 chars required' });
|
||||
if (!isRole(role)) return res.status(400).json({ error: 'invalid_role' });
|
||||
// Only an admin may create another admin (leadership tops out at 'lead').
|
||||
if (role === 'admin' && req.session.user?.role !== 'admin') return res.status(403).json({ error: 'forbidden', message: 'Only an admin can create an admin' });
|
||||
try {
|
||||
const result = await createUser(u, p, role);
|
||||
if (result === 'exists') return res.status(409).json({ error: 'exists', message: 'Username already taken' });
|
||||
res.json({ user: result });
|
||||
} catch (err) {
|
||||
console.error('POST /api/users', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/users/:username', requireRole('lead'), async (req: Request, res: Response) => {
|
||||
const target = String(req.params.username);
|
||||
const actor = req.session.user!;
|
||||
if (target === actor.username) return res.status(400).json({ error: 'self', message: 'You cannot delete your own account' });
|
||||
try {
|
||||
const targetRole = await getUserRole(target);
|
||||
if (!targetRole) return res.status(404).json({ error: 'not_found' });
|
||||
// Only an admin may remove an admin, and never the last admin (would lock everyone out).
|
||||
if (targetRole === 'admin') {
|
||||
if (actor.role !== 'admin') return res.status(403).json({ error: 'forbidden', message: 'Only an admin can remove an admin' });
|
||||
if (await countAdmins() <= 1) return res.status(400).json({ error: 'last_admin', message: 'Cannot delete the last admin' });
|
||||
}
|
||||
await deleteUser(target);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/users', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- API tokens (Admin only) -----------------------------------------------
|
||||
app.get('/api/tokens', requireRole('admin'), async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ tokens: await listTokens() });
|
||||
} catch (err) {
|
||||
console.error('GET /api/tokens', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tokens', requireRole('admin'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
const label = typeof req.body?.label === 'string' && req.body.label.trim() ? req.body.label.trim() : 'sync-extension';
|
||||
const raw = await createToken(label);
|
||||
res.json({ token: raw, label });
|
||||
} catch (err) {
|
||||
console.error('POST /api/tokens', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tokens/:id/revoke', requireRole('admin'), async (req: Request, res: Response) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'invalid_id' });
|
||||
try {
|
||||
await revokeToken(id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/tokens/:id/revoke', (err as Error).message);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Static client + SPA fallback -----------------------------------------
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
app.get('*', (req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path.startsWith('/api/')) return next();
|
||||
res.sendFile(path.join(CLIENT_DIST, 'index.html'), err => { if (err) next(); });
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await initDB();
|
||||
console.log('DB ready (forge)');
|
||||
} catch (err) {
|
||||
console.error('initDB failed:', (err as Error).message);
|
||||
process.exit(1);
|
||||
}
|
||||
app.listen(PORT, () => console.log(`FORGE listening on http://localhost:${PORT}`));
|
||||
})();
|
||||
Generated
+3555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "forge",
|
||||
"version": "2.3.2",
|
||||
"description": "FORGE 2.0 — ServiceNow ticket analytics portal (RITM/SCTASK) with roles, PM insights, and a Chrome sync extension",
|
||||
"main": "dist/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc && cp -r server/data dist/server/data && cd client && npm ci && npm run build",
|
||||
"dev": "concurrently -n server,client -c blue,green \"tsx index.ts\" \"cd client && npm run dev\"",
|
||||
"dev:server": "tsx index.ts",
|
||||
"dev:client": "cd client && npm run dev",
|
||||
"deploy": "./scripts/push-to-nas.sh",
|
||||
"reseed": "tsx server/reseed.ts",
|
||||
"dump-to-archives": "node scripts/dump-to-archives.mjs",
|
||||
"test": "cd client && npm test && cd .. && npm run test:root",
|
||||
"test:root": "vitest run -c vitest.config.ts",
|
||||
"lint": "eslint . --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"express-session": "^1.19.0",
|
||||
"pg": "^8.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/connect-pg-simple": "^7.0.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
Executable
+353
@@ -0,0 +1,353 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy.sh — build and start the forge stack on the Synology NAS, streaming
|
||||
# every stage live and ending with a clear PASS/FAIL result.
|
||||
#
|
||||
# forge has NO database migration step: the schema self-bootstraps in initDB()
|
||||
# when the app boots; the app + bundled Postgres come up together. Build -> up
|
||||
# -> health, with no migrate one-shot to wait on (unlike the utility runner).
|
||||
#
|
||||
# What it does (4 stages, each with a banner you can watch):
|
||||
# [1/4] Pull — `git pull` (only with --pull, and only in a git repo)
|
||||
# [2/4] Build — `<compose> build` (full streaming output, not quiet)
|
||||
# [3/4] Up — `<compose> up -d --remove-orphans`
|
||||
# (--remove-orphans cleans up stale containers left under
|
||||
# project `forge`)
|
||||
# [4/4] Health — poll the app container until Docker reports `healthy`
|
||||
# (≤120s), tailing app logs; fail with
|
||||
# "APP DID NOT BECOME HEALTHY" on timeout
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker + Compose available on the NAS. Prefers the v2 plugin
|
||||
# (`docker compose`, DSM 7.2+); falls back to `docker-compose`.
|
||||
# - `.env` present next to docker-compose.yml on the NAS (SESSION_SECRET + AUTH_USER +
|
||||
# AUTH_PASS + POSTGRES_PASSWORD). It is gitignored and NOT
|
||||
# baked into the image.
|
||||
# - Run from the repo root, or from anywhere — the script cd's to its own
|
||||
# parent directory (the repo root) before doing anything, so paths with
|
||||
# spaces and odd CWDs are fine.
|
||||
#
|
||||
# How to run:
|
||||
# ./scripts/deploy.sh # build + up + watch (no git pull)
|
||||
# ./scripts/deploy.sh --pull # git pull first, then build + up
|
||||
# ./scripts/deploy.sh --fresh # tear down stack first, then build + up
|
||||
# ./scripts/deploy.sh --help # show usage
|
||||
#
|
||||
# Or via DSM Task Scheduler as a user-defined script (runs head-less —
|
||||
# ANSI colour is auto-disabled when stdout is not a TTY):
|
||||
# /volume1/docker/forge/scripts/deploy.sh --pull \
|
||||
# >> /volume1/docker/forge/logs/deploy.log 2>&1
|
||||
#
|
||||
# Flags:
|
||||
# --pull Run `git pull` before building (skipped by default, since you
|
||||
# may deploy from a copied folder rather than a git checkout).
|
||||
# --fresh Tear the stack down (`down --remove-orphans`, NEVER `-v`) BEFORE
|
||||
# building, for a clean recreate. The bundled Postgres volume
|
||||
# (`forge-db`) is PRESERVED — `--fresh` never passes `-v`, so ticket
|
||||
# data survives. Parsed locally; never forwarded as a compose argument.
|
||||
# --help Print usage and exit 0.
|
||||
#
|
||||
# Ownership:
|
||||
# This script is the single source of truth for compose project `forge`.
|
||||
# `docker compose` here adopts/recreates the very same containers a
|
||||
# Container Manager GUI "Project" of that name would show — so you never
|
||||
# need the GUI to deploy. See SETUP.md ("Container Manager coexistence").
|
||||
#
|
||||
# Safety:
|
||||
# - Idempotent: safe to re-run; `up -d` only recreates containers whose
|
||||
# config/image changed.
|
||||
# - No destructive ops: never runs `down -v`, volume/image prune, or
|
||||
# anything that could drop data. `--fresh` runs `down --remove-orphans`
|
||||
# (no `-v`) which only removes containers/networks; the forge-db volume is kept.
|
||||
# - No secrets echoed.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 deploy OK (app healthy)
|
||||
# 1 precondition / build / up failure, or health timeout
|
||||
# 2 usage error (bad flag)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --- PATH hardening (Synology-aware) --------------------------------------
|
||||
# Non-interactive SSH on DSM 7.2 ships a minimal PATH (/usr/bin:/bin:...)
|
||||
# that does NOT include docker. Container Manager installs docker /
|
||||
# docker-compose under these locations. Only prepend when docker is missing,
|
||||
# so this is a no-op on dev/CI hosts where docker is already on PATH.
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
export PATH="/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:$PATH"
|
||||
fi
|
||||
|
||||
# --- Resolve repo root (works regardless of CWD; tolerates spaces) --------
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# --- Config ---------------------------------------------------------------
|
||||
APP_SERVICE="app"
|
||||
HEALTH_TIMEOUT_SECS=120 # max wait for app to become healthy
|
||||
HEALTH_POLL_SECS=3 # gap between health polls
|
||||
HOST_PORT_FALLBACK="3089" # used if we can't derive the published port
|
||||
|
||||
# Prefix prepended to every docker / compose invocation. Empty on hosts where
|
||||
# the current user can talk to the docker daemon directly (dev, CI); set to
|
||||
# "sudo" on the Synology NAS, where docker requires root. The
|
||||
# ${DOCKER_SUDO:+sudo} array idiom expands to nothing (NOT an empty arg) when
|
||||
# DOCKER_SUDO is empty, so the no-sudo path is byte-for-byte unchanged.
|
||||
DOCKER_SUDO=""
|
||||
|
||||
# --- Flags ----------------------------------------------------------------
|
||||
# --pull and --fresh are parsed here and consumed locally; neither is ever
|
||||
# forwarded to compose. (push-to-nas.sh forwards extra args verbatim to this
|
||||
# script, so `npm run deploy -- --fresh` / `-- --pull` reach this loop.)
|
||||
DO_PULL=0
|
||||
DO_FRESH=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--pull) DO_PULL=1 ;;
|
||||
--fresh) DO_FRESH=1 ;;
|
||||
--help|-h)
|
||||
sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'ERROR: unknown flag: %s (try --help)\n' "$arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Colour (only on a TTY; degrade to empty strings head-less) -----------
|
||||
if [[ -t 1 ]]; then
|
||||
C_RESET=$'\033[0m'
|
||||
C_RED=$'\033[31m'
|
||||
C_GREEN=$'\033[32m'
|
||||
C_YELLOW=$'\033[33m'
|
||||
C_BOLD=$'\033[1m'
|
||||
else
|
||||
C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD=''
|
||||
fi
|
||||
|
||||
ts() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
banner() {
|
||||
# banner "[1/4]" "Pulling latest code"
|
||||
printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET"
|
||||
}
|
||||
|
||||
log() { printf '[deploy %s] %s\n' "$(ts)" "$*"; }
|
||||
warn() { printf '%s[deploy %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; }
|
||||
|
||||
# Print a FAILED banner with a stage label, then exit non-zero.
|
||||
fail() {
|
||||
# fail "<stage>" "<message>"
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
printf '%s%s DEPLOY FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET"
|
||||
printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Detect compose command (prefer v2 plugin, fall back to v1) -----------
|
||||
# COMPOSE is an array so quoting survives word-splitting and "docker compose"
|
||||
# (two words) is handled correctly. The ${DOCKER_SUDO:+sudo} prefix is folded
|
||||
# into the array so every compose call inherits root when needed; it expands
|
||||
# to nothing when DOCKER_SUDO is empty.
|
||||
detect_compose() {
|
||||
if ${DOCKER_SUDO:+sudo} docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE=(${DOCKER_SUDO:+sudo} docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
COMPOSE=(${DOCKER_SUDO:+sudo} docker-compose)
|
||||
else
|
||||
fail "preflight" "Neither 'docker compose' (v2) nor 'docker-compose' (v1) is available. Install Docker via Synology Package Center."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Stage 0: preflight ----------------------------------------------------
|
||||
preflight() {
|
||||
banner "[0/4]" "Preflight checks"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
fail "preflight" "docker not found on PATH (looked in /usr/local/bin and Container Manager's target dir too)."
|
||||
fi
|
||||
|
||||
# Determine whether docker needs root. On the Synology NAS the deploy user
|
||||
# cannot reach the docker daemon directly, so we route every call through
|
||||
# sudo. We only commit to sudo when we have a way to authenticate:
|
||||
# - `sudo -n true` succeeds -> passwordless sudo / cached credential, OR
|
||||
# - stdout is a TTY ([ -t 1 ]) -> interactive `sudo` can prompt (caller
|
||||
# ran us via `ssh -t`); sudo caches it.
|
||||
# With neither, an interactive prompt would hang head-less, so we bail early.
|
||||
if ! docker version >/dev/null 2>&1; then
|
||||
if sudo -n true >/dev/null 2>&1 || [[ -t 1 ]]; then
|
||||
DOCKER_SUDO="sudo"
|
||||
log "docker requires sudo on this host — you may be prompted for your password once"
|
||||
else
|
||||
fail "preflight" "docker requires root and no TTY/passwordless sudo is available. Run via 'ssh -t' or configure NOPASSWD sudo for docker."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ! -f "$REPO_ROOT/.env" ]]; then
|
||||
fail "preflight" ".env not found at $REPO_ROOT/.env — create it on the NAS (SESSION_SECRET + AUTH_USER + AUTH_PASS + POSTGRES_PASSWORD) before deploying. It is gitignored and not baked into the image."
|
||||
fi
|
||||
|
||||
detect_compose
|
||||
log "repo root: $REPO_ROOT"
|
||||
log "compose command: ${COMPOSE[*]}"
|
||||
log ".env: present"
|
||||
log "this script manages compose project 'forge' directly; any Container"
|
||||
log "Manager GUI 'Project' of the same name is cosmetic — these are its containers."
|
||||
}
|
||||
|
||||
# --- Stage 1: git pull (optional) -----------------------------------------
|
||||
stage_pull() {
|
||||
banner "[1/4]" "Pulling latest code"
|
||||
if [[ "$DO_PULL" -ne 1 ]]; then
|
||||
log "skipped (no --pull flag); deploying current working tree as-is"
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$REPO_ROOT/.git" ]]; then
|
||||
warn "--pull requested but $REPO_ROOT is not a git repo; skipping pull"
|
||||
return 0
|
||||
fi
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
warn "--pull requested but git not found on PATH; skipping pull"
|
||||
return 0
|
||||
fi
|
||||
log "running git pull..."
|
||||
git pull
|
||||
log "git pull complete"
|
||||
}
|
||||
|
||||
# --- Stage 1b: optional fresh teardown (only with --fresh) ----------------
|
||||
stage_fresh() {
|
||||
if [[ "$DO_FRESH" -ne 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
banner "[1/4]" "Fresh teardown of existing stack (--fresh)"
|
||||
# NEVER pass -v: this compose HAS a named volume (forge-db) holding the ticket
|
||||
# database — dropping it would wipe all data. --remove-orphans sweeps up any
|
||||
# stale container lingering under project 'forge'; the volume is untouched.
|
||||
log "fresh mode: tearing down existing stack (forge-db volume PRESERVED)"
|
||||
"${COMPOSE[@]}" down --remove-orphans || true
|
||||
}
|
||||
|
||||
# --- Stage 2: build (full streaming output) -------------------------------
|
||||
stage_build() {
|
||||
banner "[2/4]" "Building images (this can take a few minutes on NAS hardware)"
|
||||
if ! "${COMPOSE[@]}" build; then
|
||||
fail "build" "Image build failed. See the build output above."
|
||||
fi
|
||||
log "build complete"
|
||||
}
|
||||
|
||||
# --- Stage 3: up (starts app; no migrate one-shot for forge) --------------
|
||||
stage_up() {
|
||||
banner "[3/4]" "Starting stack (app)"
|
||||
# --remove-orphans cleans up any stale container still pinned to project
|
||||
# 'forge' (e.g. left by the GUI or an old compose config). Build ran first,
|
||||
# so the app keeps serving until this quick recreate — minimal downtime.
|
||||
if ! "${COMPOSE[@]}" up -d --remove-orphans; then
|
||||
fail "up" "'up -d' failed. See the output above."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Stage 4: wait for app health -----------------------------------------
|
||||
stage_health() {
|
||||
banner "[4/4]" "Waiting for app to become healthy (timeout ${HEALTH_TIMEOUT_SECS}s)"
|
||||
|
||||
local cid
|
||||
cid="$("${COMPOSE[@]}" ps -q "$APP_SERVICE" 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -z "$cid" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP DID NOT BECOME HEALTHY — no '$APP_SERVICE' container is running. 'up' may have failed. See logs above."
|
||||
fi
|
||||
|
||||
# Show recent startup logs so the user sees progress while we poll.
|
||||
log "recent app startup logs:"
|
||||
"${COMPOSE[@]}" logs --no-color --tail 20 "$APP_SERVICE" 2>/dev/null || true
|
||||
|
||||
local deadline status running polls
|
||||
deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS ))
|
||||
polls=0
|
||||
|
||||
while true; do
|
||||
# If the container has a healthcheck, .State.Health.Status is one of
|
||||
# starting|healthy|unhealthy. If it has none, the field is empty — fall
|
||||
# back to .State.Status == running.
|
||||
status="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo "unknown")"
|
||||
running="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.State.Running}}' "$cid" 2>/dev/null || echo "false")"
|
||||
|
||||
if [[ "$status" == "healthy" ]]; then
|
||||
log "app is healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$status" == "none" && "$running" == "true" ]]; then
|
||||
warn "app container has no healthcheck; treating 'running' as healthy"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# HARD FAIL — only a TERMINAL healthcheck verdict aborts early. 'unhealthy'
|
||||
# means the healthcheck retries are exhausted. We deliberately do NOT infer
|
||||
# failure from running!=true: under `restart: unless-stopped`, a crash makes
|
||||
# 'running' transiently false while Docker restarts the container, so we
|
||||
# keep waiting (until the overall timeout) through transient states.
|
||||
if [[ "$status" == "unhealthy" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP IS UNHEALTHY — '$APP_SERVICE' healthcheck reported 'unhealthy' (retries exhausted). See app logs above."
|
||||
fi
|
||||
|
||||
if [[ "$(date +%s)" -ge "$deadline" ]]; then
|
||||
"${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true
|
||||
fail "health" "APP DID NOT BECOME HEALTHY within ${HEALTH_TIMEOUT_SECS}s (last status: ${status}/${running}). See app logs above."
|
||||
fi
|
||||
|
||||
# Heartbeat: emit a timestamped, status-bearing line every few polls so
|
||||
# head-less logs (DSM Task Scheduler, which only flushes at exit) stay
|
||||
# useful — bare dots would otherwise appear only when the script ends.
|
||||
polls=$(( polls + 1 ))
|
||||
if (( polls % 5 == 1 )); then
|
||||
log "still waiting (status=${status}/${running})..."
|
||||
fi
|
||||
sleep "$HEALTH_POLL_SECS"
|
||||
done
|
||||
}
|
||||
|
||||
# --- Derive the published host port (best effort) -------------------------
|
||||
derive_host_port() {
|
||||
local port
|
||||
port="$("${COMPOSE[@]}" port "$APP_SERVICE" 3000 2>/dev/null | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1 || true)"
|
||||
if [[ -n "$port" ]]; then
|
||||
printf '%s' "$port"
|
||||
else
|
||||
printf '%s' "$HOST_PORT_FALLBACK"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Final OK banner -------------------------------------------------------
|
||||
success_banner() {
|
||||
local host_port image
|
||||
host_port="$(derive_host_port)"
|
||||
image="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.Config.Image}}' "$("${COMPOSE[@]}" ps -q "$APP_SERVICE" | head -n 1)" 2>/dev/null || echo "forge-app:latest")"
|
||||
|
||||
printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET"
|
||||
log "app image: $image"
|
||||
log "app health: healthy"
|
||||
log "app URL: http://<nas-host>:${host_port} (LAN; or via your reverse proxy)"
|
||||
}
|
||||
|
||||
# --- Main ------------------------------------------------------------------
|
||||
main() {
|
||||
preflight
|
||||
stage_pull
|
||||
stage_fresh
|
||||
stage_build
|
||||
stage_up
|
||||
stage_health
|
||||
success_banner
|
||||
}
|
||||
|
||||
main
|
||||
@@ -0,0 +1,158 @@
|
||||
// Convert a Let it Snow chrome.storage.local dump into FORGE's seed archives +
|
||||
// a config bundle. Run: node scripts/dump-to-archives.mjs [path/to/storage-dump.json]
|
||||
// then reload the DB: npm run reseed
|
||||
//
|
||||
// Outputs (server/data/):
|
||||
// active_archive.json — { tickets:[sn_tickets], jira:{num:info} } (operational, live board)
|
||||
// closed_archive.json — { tickets:{num:merged} } (analytics master)
|
||||
// config.json — thresholds, FX, size thresholds, SLA norms, palettes
|
||||
//
|
||||
// The closed archive MERGES two dump sources by RITM number:
|
||||
// - analytics_data.ticketsMeta (954) → analytics fields: openedBy, openedDate,
|
||||
// closedDate, toDoAt, inUatAt, jiraKey, year, businessUnit, SLA minutes, cost
|
||||
// - analytics_meta_cache (965) → operational fields: lastActivityAt/By,
|
||||
// stateChangedAt/By, requestedFor, openedAt (datetime), _updated
|
||||
// ticketsMeta wins for analytics; meta_cache fills operational gaps; union of both.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dumpPath = process.argv[2] || path.join(root, 'storage-dump.json');
|
||||
const dataDir = path.join(root, 'server', 'data');
|
||||
|
||||
const dump = JSON.parse(readFileSync(dumpPath, 'utf-8'));
|
||||
const cs = dump.chromeStorageLocal ?? {};
|
||||
const wl = dump.windowLocalStorage ?? {};
|
||||
|
||||
const sn = cs.sn_tickets ?? [];
|
||||
const metaCache = cs.analytics_meta_cache ?? {};
|
||||
const ticketsMeta = cs.analytics_data?.ticketsMeta ?? [];
|
||||
const jiraRaw = cs.jira_status_map ?? {};
|
||||
|
||||
// --- active (unchanged) ----------------------------------------------------
|
||||
const jira = {};
|
||||
for (const [num, j] of Object.entries(jiraRaw)) {
|
||||
if (j && typeof j === 'object') {
|
||||
jira[num] = { status: j.status ?? null, statusChangedAt: j.statusChangedAt ?? null,
|
||||
key: j.key ?? null, url: j.url ?? null, assignee: j.assignee ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich jira with per-status durations (ms) from the board-state snapshot, keyed
|
||||
// to RITM via customfield_26001. Powers chart #17 (Jira status durations). The
|
||||
// board's column order is the workflow order used to render the chart.
|
||||
const boardState = cs.jira_board_state?.boardState ?? {};
|
||||
const jiraColumns = (boardState.columns ?? []).map(c => c?.name).filter(Boolean);
|
||||
for (const issues of Object.values(boardState.colIssues ?? {})) {
|
||||
for (const iss of (issues ?? [])) {
|
||||
const ritm = iss?.fields?.customfield_26001;
|
||||
if (!ritm || typeof ritm !== 'string') continue;
|
||||
const sd = iss._statusDurations;
|
||||
if (!sd || typeof sd !== 'object') continue;
|
||||
const e = jira[ritm] ?? (jira[ritm] = { status: null, statusChangedAt: null, key: null, url: null, assignee: null });
|
||||
e.statusDurations = sd; // { statusName: milliseconds }
|
||||
if (!e.key) e.key = iss.key ?? null;
|
||||
if (!e.status && iss.fields?.status?.name) e.status = iss.fields.status.name;
|
||||
}
|
||||
}
|
||||
|
||||
// --- merged closed ---------------------------------------------------------
|
||||
const byNumTM = {};
|
||||
for (const t of ticketsMeta) if (t?.number) byNumTM[t.number] = t;
|
||||
|
||||
const closed = {};
|
||||
const nums = new Set([...Object.keys(metaCache), ...Object.keys(byNumTM)]);
|
||||
for (const num of nums) {
|
||||
const mc = metaCache[num] ?? {};
|
||||
const tm = byNumTM[num] ?? {};
|
||||
closed[num] = {
|
||||
// analytics (ticketsMeta wins, fall back to meta_cache)
|
||||
state: tm.state ?? mc.state ?? '',
|
||||
shortDesc: tm.shortDesc ?? mc.shortDesc ?? '',
|
||||
assignedTo: tm.assignedTo ?? mc.assignedTo ?? null,
|
||||
brand: tm.brand ?? mc.brand ?? null,
|
||||
market: tm.market ?? mc.market ?? null,
|
||||
businessUnit: tm.businessUnit ?? mc.businessUnit ?? null,
|
||||
finalCost: tm.finalCost ?? mc.finalCost ?? null,
|
||||
currencyCode: tm.currencyCode ?? mc.currencyCode ?? null,
|
||||
ttfrMinutes: tm.ttfrMinutes ?? mc.ttfrMinutes ?? null,
|
||||
clientRespMinutes: tm.clientRespMinutes ?? mc.clientRespMinutes ?? null,
|
||||
firstReplyAt: tm.firstReplyAt ?? mc.firstReplyAt ?? null,
|
||||
firstAssignedDate: tm.firstAssignedDate ?? mc.firstAssignedDate ?? null,
|
||||
fulfillmentDate: tm.fulfillmentDate ?? mc.fulfillmentDate ?? null,
|
||||
openedBy: tm.openedBy ?? null,
|
||||
openedDate: tm.openedDate ?? null,
|
||||
closedDate: tm.closedDate ?? null,
|
||||
toDoAt: tm.toDoAt ?? null,
|
||||
inUatAt: tm.inUatAt ?? null,
|
||||
jiraKey: tm.jiraKey ?? null,
|
||||
year: tm.year ?? null,
|
||||
// operational (meta_cache)
|
||||
requestedFor: mc.requestedFor ?? null,
|
||||
openedAt: mc.openedAt ?? tm.openedDate ?? null,
|
||||
stateChangedAt: mc.stateChangedAt ?? null,
|
||||
stateChangedBy: mc.stateChangedBy ?? null,
|
||||
lastActivityAt: mc.lastActivityAt ?? null,
|
||||
lastActivityBy: mc.lastActivityBy ?? null,
|
||||
_updated: mc._updated ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- config bundle ---------------------------------------------------------
|
||||
const parseWl = (k, fallback) => { try { return JSON.parse(wl[k]); } catch { return fallback; } };
|
||||
const config = {
|
||||
insightsThresholds: cs.insights_thresholds ?? {},
|
||||
fxRates: cs.fx_rates_cache?.rates ?? { GBP: 1, EUR: 1.2, MXN: 20 },
|
||||
sizeThresholds: parseWl('otd_cost_thresh', { XS: 120, S: 300, M: 600, L: 1200, XL: 3000, XXL: 9000 }),
|
||||
norms: {
|
||||
otd: parseWl('otd_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
|
||||
avgdays: parseWl('avgdays_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
|
||||
asla: parseWl('asla_day_norms', { XS: 1, S: 1, M: 1, L: 1, XL: 1, XXL: 1 }),
|
||||
psla: parseWl('psla_day_norms', { XS: 3, S: 6, M: 9, L: 15, XL: 30, XXL: 30 }),
|
||||
ttfr: parseWl('lisr_ttfr_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
|
||||
cresp: parseWl('lisr_cresp_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
|
||||
},
|
||||
brandColors: cs.brand_colors ?? {},
|
||||
snowColors: cs.snow_colors ?? {},
|
||||
statesOrder: cs.sn_states_order ?? [],
|
||||
displayCurrency: cs.display_currency ?? 'GBP',
|
||||
colleagues: String(cs.colleague_names ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
||||
latamAssignees: String(cs.latam_assignees ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
|
||||
jiraColumns,
|
||||
};
|
||||
|
||||
// --- finance (per-ticket cost / PO / invoiced, from the SharePoint xlsx) ------
|
||||
// Column names are data-driven; map the known ones case-insensitively.
|
||||
const financeRaw = cs.finance_xlsx_data?.rows ?? {};
|
||||
const cell = (row, ...names) => {
|
||||
for (const k of Object.keys(row)) if (names.some(n => k.toLowerCase() === n.toLowerCase())) return row[k];
|
||||
return null;
|
||||
};
|
||||
const finance = { rows: {} };
|
||||
for (const [num, row] of Object.entries(financeRaw)) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const po = cell(row, 'PO');
|
||||
finance.rows[num] = {
|
||||
cost: cell(row, 'Cost'),
|
||||
currency: cell(row, 'Currency'),
|
||||
po: po == null ? '' : String(po).trim(), // '' = in finance but no PO; key for waiting-PO
|
||||
invoiced: cell(row, 'Invoiced'),
|
||||
milestone: cell(row, 'Milestone'),
|
||||
state: cell(row, 'State'),
|
||||
pm: cell(row, 'PM'),
|
||||
};
|
||||
}
|
||||
|
||||
const generatedAt = new Date().toISOString();
|
||||
writeFileSync(path.join(dataDir, 'finance.json'), JSON.stringify({ schema: 1, generatedAt, count: Object.keys(finance.rows).length, rows: finance.rows }));
|
||||
writeFileSync(path.join(dataDir, 'active_archive.json'),
|
||||
JSON.stringify({ schema: 2, generatedAt, count: sn.length, tickets: sn, jira }));
|
||||
writeFileSync(path.join(dataDir, 'closed_archive.json'),
|
||||
JSON.stringify({ schema: 2, generatedAt, count: Object.keys(closed).length, tickets: closed }));
|
||||
writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(config, null, 2));
|
||||
|
||||
const overlap = Object.keys(closed).filter(n => sn.some(t => t.number === n)).length;
|
||||
console.log(`active=${sn.length} closed(merged meta_cache∪ticketsMeta)=${Object.keys(closed).length} jira=${Object.keys(jira).length} overlap=${overlap} (active wins)`);
|
||||
console.log(`ticketsMeta analytics rows folded in: ${Object.keys(byNumTM).length}`);
|
||||
console.log('Wrote server/data/{active,closed}_archive.json + config.json — now run: npm run reseed');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user