Initial import of Forge app

This commit is contained in:
2026-08-29 12:30:19 +03:00
commit e637634c59
118 changed files with 17355 additions and 0 deletions
+10
View File
@@ -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)
+18
View File
@@ -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
View File
@@ -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
Generated Executable
+10
View File
@@ -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
Generated Executable
+8
View File
@@ -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>
Generated Executable
+8
View File
@@ -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>
Generated Executable
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+69
View File
@@ -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)
```
+14
View File
@@ -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>
+3725
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+7
View File
@@ -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

+15
View File
@@ -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;
}
+108
View File
@@ -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; }
}
+63
View File
@@ -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&nbsp;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); }
+70
View File
@@ -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); }
+16
View File
@@ -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); }
}
+41
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+13
View File
@@ -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); }
+124
View File
@@ -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>
);
}
+64
View File
@@ -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; }
+154
View File
@@ -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>
);
}
+85
View File
@@ -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); }
+118
View File
@@ -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); }
+82
View File
@@ -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); }
+68
View File
@@ -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); }
+123
View File
@@ -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); }
+87
View File
@@ -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); }
+32
View File
@@ -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>
);
}
+90
View File
@@ -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); }
+71
View File
@@ -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, '');
}
+25
View File
@@ -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`, {});
}
+22
View File
@@ -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');
}
+42
View File
@@ -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,
});
}
+29
View File
@@ -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', {});
}
+17
View File
@@ -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');
}
+18
View File
@@ -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;
+68
View File
@@ -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; }
+59
View File
@@ -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[];
}
+76
View File
@@ -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 }[];
}
+49
View File
@@ -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';
}
+167
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// --- 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));
}
+40
View File
@@ -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('?');
});
});
+60
View File
@@ -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();
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+22
View File
@@ -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" }]
}
+15
View File
@@ -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"]
}
+37
View File
@@ -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, '**/._*'],
},
});
+62
View File
@@ -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
View File
@@ -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).
BIN
View File
Binary file not shown.
+294
View File
@@ -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

+27
View File
@@ -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"
}
}
+70
View File
@@ -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>&lt;baseUrl&gt;/browse/&lt;KEY&gt;</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>
+86
View File
@@ -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();
+35
View File
@@ -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 &amp; token settings →</a></div>
<script src="popup.js"></script>
</body>
</html>
+44
View File
@@ -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();
+368
View File
@@ -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}`));
})();
+3555
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+353
View File
@@ -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
+158
View File
@@ -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_cacheticketsMeta)=${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