Init
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
'sidebar topbar'
|
||||
'sidebar main';
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 40px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import TopBar from './components/TopBar';
|
||||
import AuthWall from './components/AuthWall';
|
||||
import Board from './pages/Board';
|
||||
import Active from './pages/Active';
|
||||
import Closed from './pages/Closed';
|
||||
import Stats from './pages/Stats';
|
||||
import Overview from './pages/Overview';
|
||||
import SlaKpi from './pages/SlaKpi';
|
||||
import Insights from './pages/Insights';
|
||||
import Admin from './pages/Admin';
|
||||
import { roleAtLeast } from './services/auth.service';
|
||||
import { getStats } from './services/ticket.service';
|
||||
import { getMe, logout, type CurrentUser } from './services/auth.service';
|
||||
import { setUnauthorizedHandler } from './services/api.service';
|
||||
import styles from './App.module.scss';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null);
|
||||
const [authReady, setAuthReady] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [counts, setCounts] = useState({ active: 0, closed: 0 });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastSync, setLastSync] = useState<string | null>(null);
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
try {
|
||||
const s = await getStats();
|
||||
setCounts({ active: s.active, closed: s.closed });
|
||||
setLastSync(new Date().toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }));
|
||||
} catch {
|
||||
// Sidebar counts are best-effort; pages surface their own load errors.
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Bootstrap: who am I? Then load counts only when authenticated.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getMe()
|
||||
.then(me => { if (alive) { setUser(me); if (me) void loadCounts(); } })
|
||||
.catch(() => { if (alive) setUser(null); })
|
||||
.finally(() => { if (alive) setAuthReady(true); });
|
||||
return () => { alive = false; };
|
||||
}, [loadCounts]);
|
||||
|
||||
// Any API 401 drops us back to the login screen.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { if (user) void loadCounts(); }, [user, refreshKey, loadCounts]);
|
||||
|
||||
const handleLogin = useCallback((u: CurrentUser) => {
|
||||
setUser(u);
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout().catch(() => {});
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
setRefreshKey(k => k + 1);
|
||||
await loadCounts();
|
||||
setRefreshing(false);
|
||||
}, [loadCounts]);
|
||||
|
||||
if (!authReady) return null;
|
||||
if (!user) return <AuthWall onLogin={handleLogin} />;
|
||||
|
||||
const canReports = roleAtLeast(user.role, 'pm');
|
||||
const canUsers = roleAtLeast(user.role, 'lead');
|
||||
const reports = (el: React.ReactNode) => (canReports ? el : <Navigate to="/" replace />);
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<Sidebar activeCount={counts.active} closedCount={counts.closed} user={user} />
|
||||
<TopBar
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={refreshing}
|
||||
lastSync={lastSync}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
<main className={styles.main}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Board search={search} refreshKey={refreshKey} />} />
|
||||
<Route path="/closed" element={<Closed search={search} refreshKey={refreshKey} />} />
|
||||
<Route path="/active" element={reports(<Active refreshKey={refreshKey} />)} />
|
||||
<Route path="/stats" element={reports(<Stats refreshKey={refreshKey} />)} />
|
||||
<Route path="/insights" element={reports(<Insights refreshKey={refreshKey} />)} />
|
||||
<Route path="/analytics" element={reports(<Overview refreshKey={refreshKey} />)} />
|
||||
<Route path="/kpi" element={reports(<SlaKpi refreshKey={refreshKey} />)} />
|
||||
<Route path="/admin" element={canUsers ? <Admin user={user} /> : <Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at 30% 20%, #1e293b, var(--sidebar));
|
||||
padding: $s-4;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
background: var(--surface);
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: $s-8 $s-6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
justify-content: center;
|
||||
}
|
||||
.mark { font-size: 22px; }
|
||||
.name { font-size: 20px; font-weight: 700; letter-spacing: 0.16em; color: var(--text); }
|
||||
|
||||
.subtitle {
|
||||
margin: $s-2 0 $s-5;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.input {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: $s-4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: $s-3;
|
||||
padding: 8px 10px;
|
||||
border-radius: $radius;
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--primary-h); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { login, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './AuthWall.module.scss';
|
||||
|
||||
export default function AuthWall({ onLogin }: { onLogin: (user: CurrentUser) => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await login(username.trim(), password);
|
||||
if (res.success && res.user) onLogin(res.user);
|
||||
else setError(res.error || 'Invalid username or password');
|
||||
} catch {
|
||||
setError('Could not reach the server.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<form className={styles.card} onSubmit={submit}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.mark}>❄</span>
|
||||
<span className={styles.name}>FORGE 2.0</span>
|
||||
</div>
|
||||
<p className={styles.subtitle}>Sign in to view the ticket board.</p>
|
||||
|
||||
<label className={styles.label} htmlFor="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
className={styles.input}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<label className={styles.label} htmlFor="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
className={styles.input}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<button className={styles.button} type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--col);
|
||||
border-radius: $radius;
|
||||
padding: 9px 11px;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition, transform $transition;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
.top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.number {
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 700; color: var(--primary);
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
svg { opacity: 0.5; }
|
||||
&:hover { text-decoration: underline; svg { opacity: 1; } }
|
||||
}
|
||||
.topRight { display: flex; align-items: center; gap: 6px; }
|
||||
.jira { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; background: var(--primary-bg); color: var(--primary); }
|
||||
.ext { display: flex; color: var(--text-dim); &:hover { color: var(--primary); } }
|
||||
|
||||
.title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bm {
|
||||
align-self: flex-start;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.assignee { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.avatar {
|
||||
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
|
||||
background: var(--primary); color: #fff; font-size: 9px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.aName { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill {
|
||||
flex-shrink: 0; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 999px;
|
||||
background: var(--surface-alt); color: var(--text-muted); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex; gap: $s-3; font-size: 10px; color: var(--text-dim);
|
||||
border-top: 1px solid var(--border); padding-top: 5px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ExternalIcon } from '../../icons';
|
||||
import { parseDate, timeAgo, initials } from '../../../utils/format.utils';
|
||||
import type { Ticket } from '../../../types/ticket.types';
|
||||
import styles from './BoardCard.module.scss';
|
||||
|
||||
function daysSince(v: string | null): number | null {
|
||||
const d = parseDate(v);
|
||||
if (!d) return null;
|
||||
return Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
interface BoardCardProps {
|
||||
ticket: Ticket;
|
||||
color: string;
|
||||
brandColor?: string;
|
||||
onSelect: (t: Ticket) => void;
|
||||
}
|
||||
|
||||
export default function BoardCard({ ticket, color, brandColor, onSelect }: BoardCardProps) {
|
||||
const lifetime = daysSince(ticket.openedAt);
|
||||
const inState = daysSince(ticket.stateChangedAt);
|
||||
const bm = [ticket.brand, ticket.market].filter(Boolean).join(' · ');
|
||||
|
||||
return (
|
||||
<div className={styles.card} style={{ '--col': color } as React.CSSProperties} onClick={() => onSelect(ticket)}>
|
||||
<div className={styles.top}>
|
||||
{ticket.link ? (
|
||||
<a href={ticket.link} target="_blank" rel="noopener noreferrer" className={styles.number}
|
||||
onClick={e => e.stopPropagation()} title="Open in ServiceNow">
|
||||
{ticket.number}<ExternalIcon size={11} />
|
||||
</a>
|
||||
) : (
|
||||
<span className={styles.number}>{ticket.number}</span>
|
||||
)}
|
||||
{ticket.jira?.status && <span className={styles.jira}>{ticket.jira.status}</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.title} title={ticket.shortDesc}>{ticket.shortDesc || '—'}</div>
|
||||
|
||||
{bm && <span className={styles.bm} style={brandColor ? { background: `${brandColor}1a`, color: brandColor, borderColor: `${brandColor}55` } : undefined}>{bm}</span>}
|
||||
|
||||
<div className={styles.foot}>
|
||||
<span className={styles.assignee}>
|
||||
<span className={styles.avatar}>{initials(ticket.assignedTo)}</span>
|
||||
<span className={styles.aName}>{ticket.assignedTo ?? 'Unassigned'}</span>
|
||||
</span>
|
||||
{inState != null && <span className={styles.pill} title="days in current state">{inState}d</span>}
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
{lifetime != null && <span title="lifetime">⏱ {lifetime}d</span>}
|
||||
<span title="last activity">✎ {timeAgo(ticket.lastActivityAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: $s-4;
|
||||
margin-bottom: $s-5;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import styles from './PageHeader.module.scss';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, subtitle, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
{children && <div className={styles.actions}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.sidebar {
|
||||
grid-area: sidebar;
|
||||
width: var(--sidebar-w);
|
||||
background: var(--sidebar);
|
||||
color: var(--sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: $s-4 $s-3;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding: $s-2 $s-2 $s-5;
|
||||
}
|
||||
.brandMark {
|
||||
font-size: 20px;
|
||||
filter: drop-shadow(0 0 6px rgba(99, 102, 241, 0.6));
|
||||
}
|
||||
.brandName {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
color: #fff;
|
||||
}
|
||||
.brandVer {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav { flex: 1; }
|
||||
|
||||
.section {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--sidebar-dim);
|
||||
padding: 0 $s-2 $s-2;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
padding: 8px $s-2;
|
||||
border-radius: $radius;
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background $transition, color $transition;
|
||||
|
||||
&:hover { background: var(--sidebar-alt); color: #fff; }
|
||||
}
|
||||
.itemActive {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
&:hover { background: var(--primary-h); }
|
||||
}
|
||||
|
||||
.itemIcon { display: flex; opacity: 0.9; }
|
||||
.itemLabel { flex: 1; }
|
||||
|
||||
.badge {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: $s-4 $s-2 0;
|
||||
border-top: 1px solid var(--sidebar-alt);
|
||||
margin-top: $s-4;
|
||||
font-size: 11px;
|
||||
color: var(--sidebar-dim);
|
||||
}
|
||||
.version { color: var(--sidebar-dim); }
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { TicketIcon, ArchiveIcon, ChartIcon, KeyIcon } from '../icons';
|
||||
import { roleAtLeast, ROLE_LABELS, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './Sidebar.module.scss';
|
||||
|
||||
interface SidebarProps {
|
||||
activeCount: number;
|
||||
closedCount: number;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeCount, closedCount, user }: SidebarProps) {
|
||||
const canReports = roleAtLeast(user.role, 'pm');
|
||||
const canAdmin = roleAtLeast(user.role, 'lead');
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.brandMark}>❄</span>
|
||||
<span className={styles.brandName}>FORGE</span>
|
||||
<span className={styles.brandVer}>2.0</span>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<div className={styles.section}>Tickets</div>
|
||||
<NavItem to="/" label="Active board" icon={<TicketIcon />} count={activeCount} end />
|
||||
<NavItem to="/closed" label="Closed" icon={<ArchiveIcon />} count={closedCount} />
|
||||
|
||||
{canReports && <>
|
||||
<div className={styles.section} style={{ marginTop: 18 }}>Analytics</div>
|
||||
<NavItem to="/insights" label="PM Insights" icon={<ChartIcon />} />
|
||||
<NavItem to="/analytics" label="Overall stats" icon={<ChartIcon />} />
|
||||
<NavItem to="/active" label="Active stats" icon={<ChartIcon />} />
|
||||
<NavItem to="/kpi" label="PM KPIs — SLA" icon={<ChartIcon />} />
|
||||
</>}
|
||||
|
||||
{canAdmin && <>
|
||||
<div className={styles.section} style={{ marginTop: 18 }}>Admin</div>
|
||||
<NavItem to="/admin" label={user.role === 'admin' ? 'Users & Tokens' : 'Users'} icon={<KeyIcon />} />
|
||||
</>}
|
||||
</nav>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<span>{ROLE_LABELS[user.role]}</span>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
count?: number;
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ to, label, icon, count, end }: NavItemProps) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) => `${styles.item} ${isActive ? styles.itemActive : ''}`}
|
||||
>
|
||||
<span className={styles.itemIcon}>{icon}</span>
|
||||
<span className={styles.itemLabel}>{label}</span>
|
||||
{count !== undefined && count > 0 && <span className={styles.badge}>{count}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.progress { color: var(--primary); background: var(--primary-bg); border-color: rgba(99,102,241,0.25); }
|
||||
.waiting { color: var(--warning); background: var(--warning-bg); border-color: rgba(234,88,12,0.25); }
|
||||
.open { color: var(--success); background: var(--success-bg); border-color: rgba(22,163,74,0.25); }
|
||||
.done { color: var(--success); background: var(--success-bg); border-color: rgba(22,163,74,0.25); }
|
||||
.cancelled { color: var(--danger); background: var(--danger-bg); border-color: rgba(220,38,38,0.25); }
|
||||
.neutral { color: var(--neutral); background: var(--neutral-bg); border-color: rgba(100,116,139,0.25); }
|
||||
@@ -0,0 +1,16 @@
|
||||
import styles from './StatePill.module.scss';
|
||||
|
||||
// Map a ServiceNow state to a tone. Unknown states fall back to neutral.
|
||||
function tone(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s.includes('progress') || s === 'assigned') return styles.progress;
|
||||
if (s.includes('awaiting') || s.includes('hold')) return styles.waiting;
|
||||
if (s === 'open') return styles.open;
|
||||
if (s.includes('complete')) return styles.done;
|
||||
if (s.includes('cancel') || s.includes('incomplete')) return styles.cancelled;
|
||||
return styles.neutral;
|
||||
}
|
||||
|
||||
export default function StatePill({ state }: { state: string }) {
|
||||
return <span className={`${styles.pill} ${tone(state)}`}>{state || '—'}</span>;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
z-index: 100;
|
||||
animation: fade 0.12s ease;
|
||||
}
|
||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.modal {
|
||||
width: 560px;
|
||||
max-width: 94vw;
|
||||
height: 100%;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: $s-5 $s-6;
|
||||
overflow-y: auto;
|
||||
animation: slide 0.16s ease;
|
||||
}
|
||||
@keyframes slide { from { transform: translateX(24px); opacity: 0.6; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: $s-4;
|
||||
}
|
||||
.number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
font-family: var(--mono);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.ext {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
.title {
|
||||
margin-top: 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
color: var(--text);
|
||||
}
|
||||
.close {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover { background: var(--border); color: var(--text); }
|
||||
}
|
||||
|
||||
.metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
margin: $s-4 0;
|
||||
}
|
||||
.jiraBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3 $s-4;
|
||||
padding: $s-4 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.field {
|
||||
dt { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); }
|
||||
dd { margin-top: 2px; font-size: 13px; color: var(--text); }
|
||||
}
|
||||
|
||||
.block { margin-top: $s-5; }
|
||||
.blockTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-3;
|
||||
}
|
||||
.count {
|
||||
font-size: 11px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.description, .comment {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.comment {
|
||||
padding: $s-3;
|
||||
background: var(--surface-alt);
|
||||
border-radius: $radius;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.timeline { list-style: none; position: relative; }
|
||||
.event {
|
||||
display: flex;
|
||||
gap: $s-3;
|
||||
padding-bottom: $s-4;
|
||||
position: relative;
|
||||
|
||||
&:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
}
|
||||
.dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
.dotNote { background: var(--warning-bg); color: var(--warning); }
|
||||
.eventBody { flex: 1; }
|
||||
.eventTop { display: flex; align-items: center; gap: 8px; }
|
||||
.who { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.kind {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.when { font-size: 12px; color: var(--text-muted); margin-top: 1px; }
|
||||
.more { font-size: 12px; color: var(--text-dim); padding-left: 36px; }
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from 'react';
|
||||
import StatePill from '../StatePill';
|
||||
import { CloseIcon, ExternalIcon, CommentIcon, NoteIcon } from '../icons';
|
||||
import { formatDate, formatDateTime, formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './TicketDetailModal.module.scss';
|
||||
|
||||
export default function TicketDetailModal({ ticket, onClose }: { ticket: Ticket; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const activity = [...(ticket.activity ?? [])].sort((a, b) => String(b.t).localeCompare(String(a.t)));
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={onClose}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<div className={styles.number}>
|
||||
{ticket.number}
|
||||
{ticket.link && (
|
||||
<a href={ticket.link} target="_blank" rel="noopener noreferrer" className={styles.ext}>
|
||||
Open in ServiceNow <ExternalIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<h2 className={styles.title}>{ticket.shortDesc || '—'}</h2>
|
||||
</div>
|
||||
<button className={styles.close} onClick={onClose} aria-label="Close"><CloseIcon /></button>
|
||||
</header>
|
||||
|
||||
<div className={styles.metaRow}>
|
||||
<StatePill state={ticket.state} />
|
||||
{ticket.jira?.status && (ticket.jira.url
|
||||
? <a className={styles.jiraBadge} href={ticket.jira.url} target="_blank" rel="noopener noreferrer">
|
||||
Jira {ticket.jira.key ? `${ticket.jira.key} ` : ''}· {ticket.jira.status}
|
||||
</a>
|
||||
: <span className={styles.jiraBadge}>Jira: {ticket.jira.status}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className={styles.fields}>
|
||||
<Field label="Assigned to" value={ticket.assignedTo} />
|
||||
<Field label="Requested for" value={ticket.requestedFor} />
|
||||
<Field label="Group" value={ticket.assignmentGroup} />
|
||||
<Field label="Brand" value={ticket.brand} />
|
||||
<Field label="Market" value={ticket.market} />
|
||||
<Field label="Business unit" value={ticket.businessUnit} />
|
||||
<Field label="Opened" value={formatDate(ticket.openedAt)} />
|
||||
<Field label="Due" value={ticket.dueDate ? formatDate(ticket.dueDate) : null} />
|
||||
<Field label="State changed" value={formatDateTime(ticket.stateChangedAt)} />
|
||||
{ticket.status === 'closed' && <>
|
||||
<Field label="Fulfilled" value={formatDate(ticket.fulfillmentDate)} />
|
||||
<Field label="Time to first reply" value={formatMinutes(ticket.ttfrMinutes)} />
|
||||
<Field label="Client response" value={formatMinutes(ticket.clientRespMinutes)} />
|
||||
<Field label="Final cost" value={ticket.finalCost != null ? formatCost(ticket.finalCost) : null} />
|
||||
</>}
|
||||
</dl>
|
||||
|
||||
{ticket.description && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Description</h3>
|
||||
<p className={styles.description}>{ticket.description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ticket.lastComment && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Latest comment</h3>
|
||||
<p className={styles.comment}>{ticket.lastComment}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activity.length > 0 && (
|
||||
<section className={styles.block}>
|
||||
<h3 className={styles.blockTitle}>Activity <span className={styles.count}>{activity.length}</span></h3>
|
||||
<ul className={styles.timeline}>
|
||||
{activity.slice(0, 60).map((a, i) => (
|
||||
<li key={i} className={styles.event}>
|
||||
<span className={`${styles.dot} ${a.kind === 'worknote' ? styles.dotNote : ''}`}>
|
||||
{a.kind === 'worknote' ? <NoteIcon /> : <CommentIcon />}
|
||||
</span>
|
||||
<div className={styles.eventBody}>
|
||||
<div className={styles.eventTop}>
|
||||
<span className={styles.who}>{a.who}</span>
|
||||
<span className={styles.kind}>{a.kind}</span>
|
||||
</div>
|
||||
<div className={styles.when}>{formatDateTime(a.t)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{activity.length > 60 && <div className={styles.more}>+{activity.length - 60} earlier events</div>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
if (!value || value === '—') return null;
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.wrap {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 10px $s-3;
|
||||
background: var(--surface-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.right { text-align: right; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.row {
|
||||
cursor: pointer;
|
||||
transition: background $transition;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
&:hover { background: var(--row-hover); }
|
||||
|
||||
td { padding: 9px $s-3; vertical-align: middle; }
|
||||
}
|
||||
|
||||
.number {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.numLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary);
|
||||
|
||||
svg { opacity: 0.55; }
|
||||
&:hover { text-decoration: underline; svg { opacity: 1; } }
|
||||
}
|
||||
.jira {
|
||||
font-family: var(--font);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
max-width: 340px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.assignee { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assigneeName {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted { color: var(--text-muted); white-space: nowrap; }
|
||||
.num { color: var(--text); }
|
||||
|
||||
.right {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.ago { font-size: 12px; }
|
||||
.ext {
|
||||
display: flex;
|
||||
color: var(--text-dim);
|
||||
&:hover { color: var(--primary); }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import StatePill from '../StatePill';
|
||||
import { ExternalIcon } from '../icons';
|
||||
import { timeAgo, initials, formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './TicketTable.module.scss';
|
||||
|
||||
interface TicketTableProps {
|
||||
tickets: Ticket[];
|
||||
variant?: 'active' | 'closed';
|
||||
onSelect: (t: Ticket) => void;
|
||||
}
|
||||
|
||||
export default function TicketTable({ tickets, variant = 'active', onSelect }: TicketTableProps) {
|
||||
const closed = variant === 'closed';
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Short description</th>
|
||||
<th>State</th>
|
||||
<th>Assignee</th>
|
||||
{closed ? <th>Market</th> : <th>Group</th>}
|
||||
{closed ? <th className={styles.num}>TTFR</th> : <th>Brand</th>}
|
||||
{closed ? <th className={styles.num}>Cost</th> : <th className={styles.right}>Last activity</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map(t => (
|
||||
<tr key={t.number} onClick={() => onSelect(t)} className={styles.row}>
|
||||
<td className={styles.number}>
|
||||
{t.link ? (
|
||||
<a href={t.link} target="_blank" rel="noopener noreferrer" className={styles.numLink}
|
||||
onClick={e => e.stopPropagation()} title="Open in ServiceNow">
|
||||
{t.number}<ExternalIcon size={11} />
|
||||
</a>
|
||||
) : (
|
||||
<span>{t.number}</span>
|
||||
)}
|
||||
{t.jira?.status && <span className={styles.jira}>{t.jira.status}</span>}
|
||||
</td>
|
||||
<td className={styles.desc} title={t.shortDesc}>{t.shortDesc || '—'}</td>
|
||||
<td><StatePill state={t.state} /></td>
|
||||
<td>
|
||||
<span className={styles.assignee}>
|
||||
<span className={styles.avatar}>{initials(t.assignedTo)}</span>
|
||||
<span className={styles.assigneeName}>{t.assignedTo ?? '—'}</span>
|
||||
</span>
|
||||
</td>
|
||||
{closed ? (
|
||||
<td className={styles.muted}>{t.market ?? '—'}</td>
|
||||
) : (
|
||||
<td className={styles.muted} title={t.assignmentGroup ?? ''}>{shortGroup(t.assignmentGroup)}</td>
|
||||
)}
|
||||
{closed ? (
|
||||
<td className={styles.num}>{formatMinutes(t.ttfrMinutes)}</td>
|
||||
) : (
|
||||
<td className={styles.muted}>{t.brand ?? '—'}</td>
|
||||
)}
|
||||
{closed ? (
|
||||
<td className={styles.num}>{formatCost(t.finalCost)}</td>
|
||||
) : (
|
||||
<td className={styles.right}>
|
||||
<span className={styles.ago}>{timeAgo(t.lastActivityAt)}</span>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "L3 EU CORE RECKITT Marketing Web Presence" → "EU CORE"
|
||||
function shortGroup(g: string | null): string {
|
||||
if (!g) return '—';
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.topbar {
|
||||
grid-area: topbar;
|
||||
height: var(--topbar-h);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-4;
|
||||
padding: 0 $s-5;
|
||||
}
|
||||
|
||||
.searchWrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 460px;
|
||||
}
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
}
|
||||
.search {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 12px 0 34px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--bg);
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color $transition, box-shadow $transition;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
background: var(--surface);
|
||||
}
|
||||
&::placeholder { color: var(--text-dim); }
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.lastSync { font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.refresh {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background $transition, border-color $transition;
|
||||
|
||||
&:hover:not(:disabled) { background: var(--surface-alt); border-color: var(--border-strong); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding-left: $s-3;
|
||||
margin-left: $s-1;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logout {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: background $transition, color $transition;
|
||||
|
||||
&:hover { background: var(--surface-alt); color: var(--text); }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SearchIcon } from '../icons';
|
||||
import { initials } from '../../utils/format.utils';
|
||||
import type { CurrentUser } from '../../services/auth.service';
|
||||
import styles from './TopBar.module.scss';
|
||||
|
||||
interface TopBarProps {
|
||||
search: string;
|
||||
onSearch: (v: string) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
lastSync: string | null;
|
||||
user: CurrentUser;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ search, onSearch, onRefresh, refreshing, lastSync, user, onLogout }: TopBarProps) {
|
||||
return (
|
||||
<header className={styles.topbar}>
|
||||
<div className={styles.searchWrap}>
|
||||
<span className={styles.searchIcon}><SearchIcon /></span>
|
||||
<input
|
||||
className={styles.search}
|
||||
placeholder="Search tickets, assignees…"
|
||||
value={search}
|
||||
onChange={e => onSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.right}>
|
||||
{lastSync && <span className={styles.lastSync}>Updated {lastSync}</span>}
|
||||
<button className={styles.refresh} onClick={onRefresh} disabled={refreshing}>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh'}
|
||||
</button>
|
||||
<div className={styles.user}>
|
||||
<span className={styles.avatar} title={user.username}>{initials(user.username)}</span>
|
||||
<button className={styles.logout} onClick={onLogout}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.empty { color: var(--text-dim); font-size: 13px; padding: $s-4 0; }
|
||||
|
||||
.list { list-style: none; display: flex; flex-direction: column; gap: 9px; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--lw) 1fr 4.5ch;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track { height: 18px; background: var(--surface-alt); border-radius: 4px; overflow: hidden; }
|
||||
.fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--pct);
|
||||
border-radius: 4px;
|
||||
background: var(--swatch);
|
||||
transition: width 0.5s cubic-bezier(0.34, 1.2, 0.4, 1);
|
||||
}
|
||||
.value {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import styles from './BarList.module.scss';
|
||||
import type { Bucket } from '../../../types/analytics.types';
|
||||
|
||||
interface BarListProps {
|
||||
title: string;
|
||||
data: Bucket[];
|
||||
colorFor?: (b: Bucket, i: number) => string;
|
||||
format?: (n: number) => string;
|
||||
labelWidth?: number;
|
||||
empty?: string;
|
||||
}
|
||||
|
||||
export default function BarList({ title, data, colorFor, format, labelWidth = 150, empty = 'No data' }: BarListProps) {
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
{data.length === 0 ? (
|
||||
<div className={styles.empty}>{empty}</div>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{data.map((d, i) => (
|
||||
<li
|
||||
key={`${d.key}-${i}`}
|
||||
className={styles.row}
|
||||
style={{ '--pct': `${(d.count / max) * 100}%`, '--swatch': colorFor?.(d, i) ?? 'var(--primary)', '--lw': `${labelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<span className={styles.label} title={d.label}>{d.label}</span>
|
||||
<span className={styles.track}><span className={styles.fill} /></span>
|
||||
<span className={styles.value}>{format ? format(d.count) : d.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; gap: $s-3; margin-bottom: $s-4; }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); }
|
||||
.toggle {
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 24px; width: 30px; border: none; background: var(--surface); color: var(--text-muted); font-size: 12px; font-weight: 700; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
.body { display: flex; gap: $s-5; align-items: center; flex-wrap: wrap; }
|
||||
.ring { width: 180px; height: 180px; flex-shrink: 0; }
|
||||
.centerNum { font-size: 30px; font-weight: 800; fill: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.centerLbl { font-size: 10px; fill: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.legend { list-style: none; flex: 1; min-width: 180px; display: flex; flex-direction: column; gap: 5px; max-height: 200px; overflow-y: auto; }
|
||||
.legItem { display: grid; grid-template-columns: 12px 1fr auto; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.sw { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.legLabel { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legVal { font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './DonutChart.module.scss';
|
||||
import type { Bucket } from '../../../types/analytics.types';
|
||||
|
||||
const PALETTE = ['#6366f1', '#8b5cf6', '#06b6d4', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#3b82f6', '#14b8a6', '#f97316', '#a855f7', '#0ea5e9'];
|
||||
const SIZE = 200, R = 78, STROKE = 20, C = 2 * Math.PI * R;
|
||||
|
||||
export default function DonutChart({ title, data, topN = 11 }: { title: string; data: Bucket[]; topN?: number }) {
|
||||
const [pct, setPct] = useState(false);
|
||||
|
||||
const sorted = [...data].sort((a, b) => b.count - a.count);
|
||||
const head = sorted.slice(0, topN);
|
||||
const rest = sorted.slice(topN);
|
||||
const restTotal = rest.reduce((s, b) => s + b.count, 0);
|
||||
const slices = restTotal > 0 ? [...head, { key: '__others', label: `Others (${rest.length})`, count: restTotal }] : head;
|
||||
const total = slices.reduce((s, b) => s + b.count, 0) || 1;
|
||||
const color = (i: number) => (i === slices.length - 1 && restTotal > 0 ? 'var(--text-dim)' : PALETTE[i % PALETTE.length]);
|
||||
|
||||
let offset = 0;
|
||||
const arcs = slices.map((b, i) => {
|
||||
const frac = b.count / total;
|
||||
const dash = frac * C;
|
||||
const arc = { b, i, dash, gap: C - dash, rot: (offset / C) * 360, color: color(i) };
|
||||
offset += dash;
|
||||
return arc;
|
||||
});
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.toggle}>
|
||||
<button className={!pct ? styles.on : ''} onClick={() => setPct(false)}>#</button>
|
||||
<button className={pct ? styles.on : ''} onClick={() => setPct(true)}>%</button>
|
||||
</div>
|
||||
</figcaption>
|
||||
<div className={styles.body}>
|
||||
<svg className={styles.ring} viewBox={`0 0 ${SIZE} ${SIZE}`} role="img" aria-label={title}>
|
||||
<circle cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none" stroke="var(--surface-alt)" strokeWidth={STROKE} />
|
||||
{arcs.map(a => (
|
||||
<circle key={a.b.key} cx={SIZE / 2} cy={SIZE / 2} r={R} fill="none"
|
||||
stroke={a.color} strokeWidth={STROKE} strokeDasharray={`${a.dash} ${a.gap}`}
|
||||
transform={`rotate(${a.rot - 90} ${SIZE / 2} ${SIZE / 2})`}>
|
||||
<title>{`${a.b.label}: ${a.b.count} (${Math.round((a.b.count / total) * 100)}%)`}</title>
|
||||
</circle>
|
||||
))}
|
||||
<text x={SIZE / 2} y={SIZE / 2 - 4} textAnchor="middle" className={styles.centerNum}>{total}</text>
|
||||
<text x={SIZE / 2} y={SIZE / 2 + 14} textAnchor="middle" className={styles.centerLbl}>total</text>
|
||||
</svg>
|
||||
<ul className={styles.legend}>
|
||||
{arcs.map(a => (
|
||||
<li key={a.b.key} className={styles.legItem}>
|
||||
<span className={styles.sw} style={{ background: a.color }} />
|
||||
<span className={styles.legLabel} title={a.b.label}>{a.b.label}</span>
|
||||
<span className={styles.legVal}>{pct ? `${Math.round((a.b.count / total) * 100)}%` : a.b.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin-bottom: $s-4; }
|
||||
.list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.row {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: var(--lw) 1fr 4.5ch;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
|
||||
}
|
||||
.label { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 5px; }
|
||||
.chev { font-size: 9px; color: var(--text-dim); transition: transform 0.15s ease; display: inline-block; }
|
||||
.chevOpen { transform: rotate(90deg); }
|
||||
.track { height: 18px; background: var(--surface-alt); border-radius: 4px; overflow: hidden; }
|
||||
.fill { display: block; height: 100%; width: var(--pct); border-radius: 4px; background: var(--swatch); transition: width 0.5s cubic-bezier(0.34, 1.2, 0.4, 1); }
|
||||
.value { text-align: right; font-size: 12px; font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.children { list-style: none; margin: 6px 0 4px 16px; display: flex; flex-direction: column; gap: 5px; padding-left: 8px; border-left: 2px solid var(--border); }
|
||||
.childRow { display: grid; grid-template-columns: var(--lw) 1fr 4.5ch; align-items: center; gap: $s-3; }
|
||||
.childLabel { font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.childFill { display: block; height: 100%; width: var(--pct); border-radius: 4px; background: var(--text-dim); }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './ExpandBarList.module.scss';
|
||||
import type { NestedBucket, Bucket } from '../../../types/analytics.types';
|
||||
|
||||
interface ExpandBarListProps {
|
||||
title: string;
|
||||
data: NestedBucket[];
|
||||
colorFor?: (b: Bucket, i: number) => string;
|
||||
labelWidth?: number;
|
||||
}
|
||||
|
||||
export default function ExpandBarList({ title, data, colorFor, labelWidth = 150 }: ExpandBarListProps) {
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
const toggle = (k: string) => setOpen(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n; });
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
<ul className={styles.list}>
|
||||
{data.map((d, i) => {
|
||||
const isOpen = open.has(d.key);
|
||||
const childMax = Math.max(1, ...d.children.map(c => c.count));
|
||||
return (
|
||||
<li key={d.key}>
|
||||
<button
|
||||
className={styles.row}
|
||||
onClick={() => d.children.length && toggle(d.key)}
|
||||
style={{ '--pct': `${(d.count / max) * 100}%`, '--swatch': colorFor?.(d, i) ?? 'var(--primary)', '--lw': `${labelWidth}px` } as React.CSSProperties}
|
||||
>
|
||||
<span className={styles.label} title={d.label}>
|
||||
{d.children.length > 0 && <span className={`${styles.chev} ${isOpen ? styles.chevOpen : ''}`}>▸</span>}
|
||||
{d.label}
|
||||
</span>
|
||||
<span className={styles.track}><span className={styles.fill} /></span>
|
||||
<span className={styles.value}>{d.count}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className={styles.children}>
|
||||
{d.children.map(c => (
|
||||
<li key={c.key} className={styles.childRow} style={{ '--pct': `${(c.count / childMax) * 100}%`, '--lw': `${labelWidth}px` } as React.CSSProperties}>
|
||||
<span className={styles.childLabel} title={c.label}>{c.label}</span>
|
||||
<span className={styles.track}><span className={styles.childFill} /></span>
|
||||
<span className={styles.value}>{c.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg; padding: $s-5; box-shadow: var(--shadow-sm); }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; gap: $s-3; margin-bottom: $s-2; }
|
||||
.title { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); }
|
||||
.controls { display: flex; align-items: center; gap: $s-3; }
|
||||
.yoy { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
|
||||
.up { color: var(--success); background: var(--success-bg); }
|
||||
.down { color: var(--danger); background: var(--danger-bg); }
|
||||
.toggle {
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 26px; padding: 0 10px; border: none; background: var(--surface); color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
.legend { display: flex; gap: $s-4; flex-wrap: wrap; margin-bottom: $s-2; }
|
||||
.leg { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); b { color: var(--text); font-variant-numeric: tabular-nums; } }
|
||||
.sw { width: 12px; height: 8px; border-radius: 2px; }
|
||||
.svg { width: 100%; height: auto; display: block; }
|
||||
.axis { font-size: 9px; fill: var(--text-dim); font-family: var(--mono); }
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './GroupedBars.module.scss';
|
||||
import type { MonthPoint } from '../../../types/analytics.types';
|
||||
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const YEAR_COLORS = ['#f97316', '#1a73e8', '#16a34a', '#dc2626', '#7c3aed'];
|
||||
const W = 700, H = 250, PADX = 40, PADT = 20, PADB = 42;
|
||||
|
||||
interface GroupedBarsProps {
|
||||
title: string;
|
||||
points: MonthPoint[]; // YYYY-MM
|
||||
format?: (n: number) => string;
|
||||
defaultMode?: 'bars' | 'line';
|
||||
}
|
||||
|
||||
export default function GroupedBars({ title, points, format, defaultMode = 'bars' }: GroupedBarsProps) {
|
||||
const [mode, setMode] = useState<'bars' | 'line'>(defaultMode);
|
||||
|
||||
const years = [...new Set(points.map(p => Number(p.month.slice(0, 4))))].sort();
|
||||
const yearColor = (y: number) => YEAR_COLORS[years.indexOf(y) % YEAR_COLORS.length];
|
||||
// grid[monthIdx][year] = count
|
||||
const grid: Record<number, Record<number, number>> = {};
|
||||
for (const p of points) {
|
||||
const y = Number(p.month.slice(0, 4));
|
||||
const mi = Number(p.month.slice(5, 7)) - 1;
|
||||
(grid[mi] ??= {})[y] = (grid[mi]?.[y] ?? 0) + p.count;
|
||||
}
|
||||
const max = Math.max(1, ...points.map(p => p.count));
|
||||
const totals = Object.fromEntries(years.map(y => [y, points.filter(p => p.month.startsWith(String(y))).reduce((s, p) => s + p.count, 0)]));
|
||||
const yoy = years.length >= 2 ? pctDelta(totals[years[years.length - 1]], totals[years[years.length - 2]]) : null;
|
||||
|
||||
const innerW = W - PADX * 2, innerH = H - PADT - PADB;
|
||||
const groupW = innerW / 12;
|
||||
const x = (mi: number) => PADX + mi * groupW;
|
||||
const y = (v: number) => PADT + (1 - v / max) * innerH;
|
||||
const tick = (v: number) => (format ? format(v) : String(v));
|
||||
|
||||
const linePath = (yr: number) => MONTHS
|
||||
.map((_, mi) => ({ mi, v: grid[mi]?.[yr] })).filter(p => p.v != null)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${(x(p.mi) + groupW / 2).toFixed(1)} ${y(p.v!).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<div className={styles.controls}>
|
||||
{yoy != null && <span className={`${styles.yoy} ${yoy >= 0 ? styles.up : styles.down}`}>{yoy >= 0 ? '▲' : '▼'} {Math.abs(yoy)}% YoY</span>}
|
||||
<div className={styles.toggle}>
|
||||
<button className={mode === 'bars' ? styles.on : ''} onClick={() => setMode('bars')}>Bars</button>
|
||||
<button className={mode === 'line' ? styles.on : ''} onClick={() => setMode('line')}>Line</button>
|
||||
</div>
|
||||
</div>
|
||||
</figcaption>
|
||||
<div className={styles.legend}>
|
||||
{years.map(yr => <span key={yr} className={styles.leg}><span className={styles.sw} style={{ background: yearColor(yr) }} />{yr} <b>{tick(totals[yr])}</b></span>)}
|
||||
</div>
|
||||
<svg className={styles.svg} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={title} preserveAspectRatio="xMidYMid meet">
|
||||
{[0, 0.5, 1].map(f => {
|
||||
const gy = PADT + f * innerH;
|
||||
return <g key={f}>
|
||||
<line x1={PADX} y1={gy} x2={W - PADX} y2={gy} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={6} y={gy + 3} className={styles.axis}>{tick(Math.round(max * (1 - f)))}</text>
|
||||
</g>;
|
||||
})}
|
||||
{mode === 'bars'
|
||||
? MONTHS.map((_, mi) => {
|
||||
const yrs = years.filter(yr => grid[mi]?.[yr] != null);
|
||||
const bw = (groupW * 0.7) / Math.max(1, yrs.length);
|
||||
return yrs.map((yr, i) => {
|
||||
const v = grid[mi]![yr];
|
||||
const bx = x(mi) + groupW * 0.15 + i * bw;
|
||||
return <rect key={`${mi}-${yr}`} x={bx} y={y(v)} width={Math.max(1, bw - 1)} height={PADT + innerH - y(v)} rx="1" fill={yearColor(yr)}><title>{`${MONTHS[mi]} ${yr}: ${tick(v)}`}</title></rect>;
|
||||
});
|
||||
})
|
||||
: years.map(yr => <path key={yr} d={linePath(yr)} fill="none" stroke={yearColor(yr)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />)}
|
||||
{MONTHS.map((m, mi) => <text key={m} x={x(mi) + groupW / 2} y={H - 22} textAnchor="middle" className={styles.axis}>{m}</text>)}
|
||||
</svg>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function pctDelta(cur: number, prev: number): number | null {
|
||||
if (!prev) return null;
|
||||
return Math.round(((cur - prev) / prev) * 100);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.tile {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
border-top: 3px solid var(--primary);
|
||||
|
||||
&[data-tone='success'] { border-top-color: var(--success); }
|
||||
&[data-tone='warning'] { border-top-color: var(--warning); }
|
||||
&[data-tone='danger'] { border-top-color: var(--danger); }
|
||||
&[data-tone='neutral'] { border-top-color: var(--neutral); }
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
}
|
||||
.label { margin-top: 8px; font-size: 13px; color: var(--text-muted); }
|
||||
.hint { margin-top: 2px; font-size: 11px; color: var(--text-dim); }
|
||||
@@ -0,0 +1,18 @@
|
||||
import styles from './KpiTile.module.scss';
|
||||
|
||||
interface KpiTileProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
tone?: 'primary' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export default function KpiTile({ label, value, tone = 'primary', hint }: KpiTileProps) {
|
||||
return (
|
||||
<div className={styles.tile} data-tone={tone}>
|
||||
<div className={styles.value}>{value}</div>
|
||||
<div className={styles.label}>{label}</div>
|
||||
{hint && <div className={styles.hint}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.head { display: flex; align-items: baseline; justify-content: space-between; gap: $s-3; margin-bottom: $s-4; }
|
||||
.title { font-size: 13px; font-weight: 700; color: var(--text); }
|
||||
.sub { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.scroll { overflow-x: auto; }
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pmHead, .sizeHead, .totHead {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pmHead { text-align: left; }
|
||||
.sizeHead { display: table-cell; }
|
||||
.sizeChip {
|
||||
display: inline-block;
|
||||
min-width: 26px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.norm { display: block; margin-top: 2px; font-size: 9px; color: var(--text-dim); font-weight: 500; }
|
||||
|
||||
.pm {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
max-width: 170px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: $s-2;
|
||||
}
|
||||
.cell, .total {
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
min-width: 52px;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
.total { background: var(--primary-bg); }
|
||||
.primary { display: block; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.meta { display: block; font-size: 10px; color: var(--text-muted); }
|
||||
.dot { color: var(--text-dim); }
|
||||
@@ -0,0 +1,82 @@
|
||||
import styles from './SlaHeatmap.module.scss';
|
||||
import type { SlaMetric, SlaCell } from '../../../types/analytics.types';
|
||||
|
||||
const SIZE_COLORS: Record<string, string> = {
|
||||
XS: '#14b8a6', S: '#1a73e8', M: '#f59e0b', L: '#10b981', XL: '#8b5cf6', XXL: '#ef4444',
|
||||
};
|
||||
|
||||
function normDays(norm: number | undefined, unit: 'days' | 'hours'): number | undefined {
|
||||
if (norm == null) return undefined;
|
||||
return unit === 'hours' ? norm / 24 : norm;
|
||||
}
|
||||
|
||||
// Value → background tint. Green when at/under norm, red when over, scaled.
|
||||
function cellTint(cell: SlaCell, norm: number | undefined): string | undefined {
|
||||
if (cell.avgDays == null || norm == null || norm <= 0) return undefined;
|
||||
const ratio = cell.avgDays / norm;
|
||||
if (ratio <= 1) {
|
||||
const t = Math.max(0.12, 1 - ratio); // deeper green the further under
|
||||
return `rgba(22, 163, 74, ${(0.10 + t * 0.22).toFixed(3)})`;
|
||||
}
|
||||
const over = Math.min(2, ratio - 1);
|
||||
return `rgba(220, 38, 38, ${(0.10 + over * 0.20).toFixed(3)})`;
|
||||
}
|
||||
|
||||
function fmt(cell: SlaCell, unit: 'days' | 'hours'): string {
|
||||
if (cell.avgDays == null) return '·';
|
||||
const v = unit === 'hours' ? cell.avgDays * 24 : cell.avgDays;
|
||||
return unit === 'hours' ? `${v.toFixed(1)}h` : `${v.toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export default function SlaHeatmap({ metric }: { metric: SlaMetric }) {
|
||||
const { title, unit, norms, pms, sizes, grid, totals } = metric;
|
||||
const isOtd = metric.key === 'otd';
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.head}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
<span className={styles.sub}>{isOtd ? 'cell = on-time % · avg' : `avg ${unit} vs norm`}</span>
|
||||
</figcaption>
|
||||
<div className={styles.scroll}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.pmHead}>PM</th>
|
||||
{sizes.map(s => (
|
||||
<th key={s} className={styles.sizeHead}>
|
||||
<span className={styles.sizeChip} style={{ background: SIZE_COLORS[s] }}>{s}</span>
|
||||
{norms[s] != null && <span className={styles.norm}>≤{norms[s]}{unit === 'hours' ? 'h' : 'd'}</span>}
|
||||
</th>
|
||||
))}
|
||||
<th className={styles.totHead}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pms.map(pm => (
|
||||
<tr key={pm}>
|
||||
<td className={styles.pm} title={pm}>{pm}</td>
|
||||
{sizes.map(s => {
|
||||
const c = grid[pm]?.[s];
|
||||
const nd = normDays(norms[s], unit);
|
||||
if (!c || c.count === 0) return <td key={s} className={styles.cell}><span className={styles.dot}>·</span></td>;
|
||||
return (
|
||||
<td key={s} className={styles.cell} style={{ background: cellTint(c, nd) }}
|
||||
title={`${c.count} tickets · avg ${fmt(c, unit)}${c.onTimePct != null ? ` · ${c.onTimePct}% on-time` : ''}`}>
|
||||
<span className={styles.primary}>{isOtd ? (c.onTimePct != null ? `${c.onTimePct}%` : '·') : fmt(c, unit)}</span>
|
||||
<span className={styles.meta}>{isOtd ? fmt(c, unit) : `${c.count}`}</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={styles.total}>
|
||||
<span className={styles.primary}>{totals[pm]?.onTimePct != null ? `${totals[pm].onTimePct}%` : (totals[pm] ? fmt(totals[pm], unit) : '·')}</span>
|
||||
<span className={styles.meta}>{totals[pm]?.count ?? 0}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@use '../../../styles/variables' as *;
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-3;
|
||||
}
|
||||
.legend { display: flex; gap: $s-4; margin-bottom: $s-2; flex-wrap: wrap; }
|
||||
.legItem { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); }
|
||||
.swatch { width: 12px; height: 3px; border-radius: 2px; }
|
||||
.svg { width: 100%; height: auto; display: block; }
|
||||
.axis { font-size: 9px; fill: var(--text-dim); font-family: var(--mono); }
|
||||
.empty { color: var(--text-dim); font-size: 13px; padding: $s-4 0; }
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useId } from 'react';
|
||||
import styles from './TrendChart.module.scss';
|
||||
import type { MonthPoint } from '../../../types/analytics.types';
|
||||
|
||||
interface Series { label: string; color: string; points: MonthPoint[]; }
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
series: Series[];
|
||||
format?: (n: number) => string;
|
||||
area?: boolean;
|
||||
}
|
||||
|
||||
const W = 640, H = 220, PADX = 34, PADT = 16, PADB = 30;
|
||||
|
||||
export default function TrendChart({ title, series, format, area }: TrendChartProps) {
|
||||
const gid = useId().replace(/:/g, '');
|
||||
const months = [...new Set(series.flatMap(s => s.points.map(p => p.month)))].sort();
|
||||
const max = Math.max(1, ...series.flatMap(s => s.points.map(p => p.count)));
|
||||
const x = (i: number) => PADX + (months.length <= 1 ? 0 : (i / (months.length - 1)) * (W - PADX * 2));
|
||||
const y = (v: number) => PADT + (1 - v / max) * (H - PADT - PADB);
|
||||
|
||||
const line = (pts: MonthPoint[]) => {
|
||||
const byMonth = new Map(pts.map(p => [p.month, p.count]));
|
||||
return months.map((m, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(byMonth.get(m) ?? 0).toFixed(1)}`).join(' ');
|
||||
};
|
||||
|
||||
if (months.length < 2) {
|
||||
return <figure className={styles.card}><figcaption className={styles.title}>{title}</figcaption><div className={styles.empty}>Not enough data</div></figure>;
|
||||
}
|
||||
|
||||
const tick = (v: number) => (format ? format(v) : String(v));
|
||||
const labelEvery = Math.ceil(months.length / 8);
|
||||
|
||||
return (
|
||||
<figure className={styles.card}>
|
||||
<figcaption className={styles.title}>{title}</figcaption>
|
||||
<div className={styles.legend}>
|
||||
{series.map(s => <span key={s.label} className={styles.legItem}><span className={styles.swatch} style={{ background: s.color }} />{s.label}</span>)}
|
||||
</div>
|
||||
<svg className={styles.svg} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={title} preserveAspectRatio="xMidYMid meet">
|
||||
{[0, 0.5, 1].map(f => {
|
||||
const gy = PADT + f * (H - PADT - PADB);
|
||||
return <g key={f}>
|
||||
<line x1={PADX} y1={gy} x2={W - PADX} y2={gy} stroke="var(--border)" strokeWidth="1" />
|
||||
<text x={4} y={gy + 3} className={styles.axis}>{tick(Math.round(max * (1 - f)))}</text>
|
||||
</g>;
|
||||
})}
|
||||
{area && series.length === 1 && (
|
||||
<>
|
||||
<defs>
|
||||
<linearGradient id={`g${gid}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={series[0].color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={series[0].color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${line(series[0].points)} L ${x(months.length - 1)} ${y(0)} L ${x(0)} ${y(0)} Z`} fill={`url(#g${gid})`} />
|
||||
</>
|
||||
)}
|
||||
{series.map(s => <path key={s.label} d={line(s.points)} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />)}
|
||||
{months.map((m, i) => (i % labelEvery === 0
|
||||
? <text key={m} x={x(i)} y={H - 8} textAnchor="middle" className={styles.axis}>{m.slice(2)}</text>
|
||||
: null))}
|
||||
</svg>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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 SpreadsheetIcon({ size = 15 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Excel-brand icon: green rounded tile with the white "X" + a hint of grid.
|
||||
// Self-colored (own fills), so it reads as Excel regardless of button text colour.
|
||||
export function ExcelIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="3.2" fill="#107C41" />
|
||||
<path d="M8.4 8.2 15.6 15.8M15.6 8.2 8.4 15.8" stroke="#fff" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon({ size = 16 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="m10.7 12.3 9.3-9.3M17 5l2 2M15 7l1.5 1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.5 8.5 0 0 1 12.5 3 8.38 8.38 0 0 1 21 11.5Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteIcon({ size = 14 }: IconProps) {
|
||||
return (
|
||||
<svg {...base(size)}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
|
||||
<path d="M14 2v6h6M8 13h8M8 17h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './styles/globals.scss';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: $s-3;
|
||||
}
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
@media (max-width: 1000px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.state, .stateError { padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import BarList from '../../components/charts/BarList';
|
||||
import TrendChart from '../../components/charts/TrendChart';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { getConfig, getJiraDurations } from '../../services/analytics.service';
|
||||
import { parseDate } from '../../utils/format.utils';
|
||||
import { BOARD_COLUMNS, boardColumn } from '../../utils/board';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import type { ForgeConfig, Bucket, MonthPoint, JiraDuration } from '../../types/analytics.types';
|
||||
import styles from './Active.module.scss';
|
||||
|
||||
const daysSince = (v: string | null): number | null => {
|
||||
const d = parseDate(v);
|
||||
return d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : null;
|
||||
};
|
||||
const median = (xs: number[]): number => {
|
||||
if (!xs.length) return 0;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return Math.round(s[Math.floor(s.length / 2)]);
|
||||
};
|
||||
|
||||
export default function Active({ refreshKey }: { refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [jiraDur, setJiraDur] = useState<JiraDuration[]>([]);
|
||||
const [error, setError] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
Promise.all([
|
||||
getTickets({ status: 'active' }),
|
||||
getConfig().catch(() => ({})),
|
||||
getJiraDurations().then(r => r.rows).catch(() => []),
|
||||
])
|
||||
.then(([t, c, jd]) => { if (alive) { setTickets(t); setCfg(c); setJiraDur(jd); } })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setReady(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
const colleagues = cfg.colleagues ?? [];
|
||||
|
||||
const model = useMemo(() => {
|
||||
const colOf = (t: Ticket) => boardColumn(t, colleagues);
|
||||
// by status (column)
|
||||
const byStatus: Bucket[] = BOARD_COLUMNS.map(c => ({
|
||||
key: c.key, label: c.label, count: tickets.filter(t => colOf(t) === c.key).length,
|
||||
})).filter(b => b.count > 0);
|
||||
// opened by month
|
||||
const om = new Map<string, number>();
|
||||
for (const t of tickets) {
|
||||
const d = parseDate(t.openedAt);
|
||||
if (d) { const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; om.set(k, (om.get(k) ?? 0) + 1); }
|
||||
}
|
||||
const openedByMonth: MonthPoint[] = [...om.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, count]) => ({ month, count }));
|
||||
// age histogram
|
||||
const AGE = [[0, 50], [50, 100], [100, 150], [150, 200], [200, Infinity]];
|
||||
const ageHist: Bucket[] = AGE.map(([lo, hi]) => ({
|
||||
key: `${lo}`, label: hi === Infinity ? `${lo}+ d` : `${lo}–${hi} d`,
|
||||
count: tickets.filter(t => { const a = daysSince(t.openedAt); return a != null && a >= lo && a < hi; }).length,
|
||||
}));
|
||||
// median days in current status per column
|
||||
const timeInStatus: Bucket[] = BOARD_COLUMNS.map(c => {
|
||||
const ds = tickets.filter(t => colOf(t) === c.key).map(t => daysSince(t.stateChangedAt)).filter((v): v is number => v != null);
|
||||
return { key: c.key, label: c.label, count: median(ds) };
|
||||
}).filter(b => b.count > 0);
|
||||
// by brand
|
||||
const bm = new Map<string, number>();
|
||||
for (const t of tickets) if (t.brand) bm.set(t.brand, (bm.get(t.brand) ?? 0) + 1);
|
||||
const byBrand: Bucket[] = [...bm.entries()].map(([k, count]) => ({ key: k, label: k, count })).sort((a, b) => b.count - a.count);
|
||||
|
||||
return { byStatus, openedByMonth, ageHist, timeInStatus, byBrand };
|
||||
}, [tickets, colleagues]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load active analytics.</div>;
|
||||
if (!ready) return <div className={styles.state}>Loading…</div>;
|
||||
|
||||
const colColor = (b: Bucket) => BOARD_COLUMNS.find(c => c.key === b.key)?.color ?? 'var(--primary)';
|
||||
const count = (key: string) => model.byStatus.find(b => b.key === key)?.count ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active statistics" subtitle={`${tickets.length} open tickets`} />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<KpiTile label="Open tickets" value={tickets.length} />
|
||||
<KpiTile label="Unassigned" value={count('unassigned')} tone="neutral" />
|
||||
<KpiTile label="In progress" value={count('wip')} tone="primary" />
|
||||
<KpiTile label="Customer replied" value={count('replied')} tone="danger" />
|
||||
<KpiTile label="Awaiting info" value={count('awaiting')} tone="warning" />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="By status" data={model.byStatus} colorFor={colColor} labelWidth={150} />
|
||||
<BarList title="Age distribution (days open)" data={model.ageHist} labelWidth={90}
|
||||
colorFor={(_, i) => ['#16a34a', '#22c55e', '#f59e0b', '#ea580c', '#dc2626'][i] ?? 'var(--primary)'} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<TrendChart title="Opened per month (still open)" area
|
||||
series={[{ label: 'Opened', color: 'var(--primary)', points: model.openedByMonth }]} />
|
||||
<BarList title="Median days in current status" data={model.timeInStatus} colorFor={colColor}
|
||||
format={n => `${n}d`} labelWidth={150} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="Active tickets by brand" data={model.byBrand} labelWidth={130} />
|
||||
{jiraDur.length > 0 && (
|
||||
<BarList
|
||||
title="Jira status durations (avg days)"
|
||||
data={jiraDur.map(d => ({ key: d.status, label: `${d.status} · ${d.count}`, count: d.avgDays }))}
|
||||
format={n => (n < 1 ? `${Math.round(n * 24)}h` : `${n}d`)}
|
||||
colorFor={(_, i) => `hsl(${(i * 37) % 360} 62% 55%)`}
|
||||
labelWidth={200}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
button {
|
||||
height: 36px; padding: 0 16px; border: none; background: transparent;
|
||||
color: var(--text-muted); font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
&.on { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
&:hover:not(.on) { color: var(--text); }
|
||||
}
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: 340px 1fr; gap: $s-4; align-items: start; @media (max-width: 900px) { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg;
|
||||
padding: $s-5; box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cardTitle { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: $s-4; }
|
||||
.hint { font-size: 12px; color: var(--text-muted); margin-bottom: $s-3; margin-top: -8px; }
|
||||
|
||||
.label { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 12px 0 5px; }
|
||||
.input {
|
||||
width: 100%; height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius;
|
||||
font-size: 13px; color: var(--text); background: var(--surface); outline: none;
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
.err { margin-top: 10px; padding: 8px 10px; border-radius: $radius; background: var(--danger-bg); color: var(--danger); font-size: 12px; }
|
||||
.btn {
|
||||
margin-top: 14px; width: 100%; height: 38px; border: none; border-radius: $radius;
|
||||
background: var(--primary); color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
&:hover:not(:disabled) { background: var(--primary-h); }
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.minted { margin-top: 14px; padding: 12px; border: 1px solid var(--success); border-radius: $radius; background: var(--success-bg); }
|
||||
.mintedLabel { font-size: 11px; font-weight: 700; color: var(--success); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; }
|
||||
.mintedToken { display: block; font-family: var(--mono); font-size: 11px; word-break: break-all; color: var(--text); cursor: pointer; background: var(--surface); padding: 8px; border-radius: $radius; }
|
||||
.copy { margin-top: 8px; height: 28px; padding: 0 12px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
|
||||
.table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
}
|
||||
.muted { color: var(--text-muted); }
|
||||
.mono { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
|
||||
.right { text-align: right; }
|
||||
.roleBadge {
|
||||
font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px;
|
||||
background: var(--surface-alt); color: var(--text-muted);
|
||||
&[data-role='admin'] { background: var(--danger-bg); color: var(--danger); }
|
||||
&[data-role='lead'] { background: var(--violet-bg, rgba(139,92,246,.1)); color: #7c3aed; }
|
||||
&[data-role='pm'] { background: var(--primary-bg); color: var(--primary); }
|
||||
}
|
||||
.danger { height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: $radius; background: var(--surface); color: var(--danger); font-size: 12px; font-weight: 600; cursor: pointer; &:hover { background: var(--danger-bg); } }
|
||||
.revoked { font-size: 12px; color: var(--text-dim); font-style: italic; }
|
||||
.revokedRow { opacity: 0.5; }
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { listUsers, createUser, deleteUser, listTokens, createToken, revokeToken, type AppUser, type TokenInfo } from '../../services/admin.service';
|
||||
import { ROLE_LABELS, roleAtLeast, type Role, type CurrentUser } from '../../services/auth.service';
|
||||
import styles from './Admin.module.scss';
|
||||
|
||||
const fmt = (v: string | null) => (v ? new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—');
|
||||
|
||||
export default function Admin({ user }: { user: CurrentUser }) {
|
||||
const isAdmin = user.role === 'admin';
|
||||
const [tab, setTab] = useState<'users' | 'tokens'>('users');
|
||||
useEffect(() => { if (!isAdmin) setTab('users'); }, [isAdmin]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Administration" subtitle="Users and API access" />
|
||||
<div className={styles.tabs}>
|
||||
<button className={tab === 'users' ? styles.on : ''} onClick={() => setTab('users')}>Users</button>
|
||||
{isAdmin && <button className={tab === 'tokens' ? styles.on : ''} onClick={() => setTab('tokens')}>API Tokens</button>}
|
||||
</div>
|
||||
{tab === 'users' ? <UsersPanel me={user} /> : <TokensPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPanel({ me }: { me: CurrentUser }) {
|
||||
const [users, setUsers] = useState<AppUser[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<Role>('viewer');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => { listUsers().then(setUsers).catch(() => setError('Failed to load users')); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Leadership can grant up to 'lead'; only admin can grant 'admin'.
|
||||
const grantable: Role[] = me.role === 'admin' ? ['viewer', 'pm', 'lead', 'admin'] : ['viewer', 'pm', 'lead'];
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await createUser(username.trim(), password, role);
|
||||
if (res.user) { setUsername(''); setPassword(''); setRole('viewer'); load(); }
|
||||
else setError(res.message || 'Could not create user');
|
||||
} catch { setError('Could not create user'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const remove = async (u: string) => {
|
||||
if (!confirm(`Delete user "${u}"?`)) return;
|
||||
await deleteUser(u).catch(() => {});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<form className={styles.card} onSubmit={submit}>
|
||||
<h3 className={styles.cardTitle}>Add user</h3>
|
||||
<label className={styles.label}>Username</label>
|
||||
<input className={styles.input} value={username} onChange={e => setUsername(e.target.value)} autoComplete="off" />
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="new-password" placeholder="min 6 chars" />
|
||||
<label className={styles.label}>Role</label>
|
||||
<select className={styles.input} value={role} onChange={e => setRole(e.target.value as Role)}>
|
||||
{grantable.map(r => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
|
||||
</select>
|
||||
{error && <div className={styles.err}>{error}</div>}
|
||||
<button className={styles.btn} type="submit" disabled={busy || username.trim().length < 2 || password.length < 6}>
|
||||
{busy ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3 className={styles.cardTitle}>Users ({users.length})</h3>
|
||||
<table className={styles.table}>
|
||||
<thead><tr><th>Username</th><th>Role</th><th>Last login</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td><span className={styles.roleBadge} data-role={u.role}>{ROLE_LABELS[u.role]}</span></td>
|
||||
<td className={styles.muted}>{fmt(u.lastLoginAt)}</td>
|
||||
<td className={styles.right}>
|
||||
{u.username !== me.username && <button className={styles.danger} onClick={() => remove(u.username)}>Delete</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokensPanel() {
|
||||
const [tokens, setTokens] = useState<TokenInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [minted, setMinted] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(() => { listTokens().then(setTokens).catch(() => {}); }, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await createToken(label.trim() || 'sync-extension');
|
||||
setMinted(res.token); setLabel(''); load();
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
const revoke = async (id: number) => { if (confirm('Revoke this token?')) { await revokeToken(id).catch(() => {}); load(); } };
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<form className={styles.card} onSubmit={create}>
|
||||
<h3 className={styles.cardTitle}>Create API token</h3>
|
||||
<p className={styles.hint}>For the FORGE Snow Sync Chrome extension. The raw token is shown once.</p>
|
||||
<label className={styles.label}>Label</label>
|
||||
<input className={styles.input} value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. my laptop" />
|
||||
<button className={styles.btn} type="submit" disabled={busy}>{busy ? 'Creating…' : 'Create token'}</button>
|
||||
{minted && (
|
||||
<div className={styles.minted}>
|
||||
<div className={styles.mintedLabel}>Copy now — shown once:</div>
|
||||
<code className={styles.mintedToken} onClick={() => navigator.clipboard?.writeText(minted)}>{minted}</code>
|
||||
<button type="button" className={styles.copy} onClick={() => navigator.clipboard?.writeText(minted)}>Copy</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3 className={styles.cardTitle}>Tokens ({tokens.length})</h3>
|
||||
<table className={styles.table}>
|
||||
<thead><tr><th>Label</th><th>ID</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{tokens.map(t => (
|
||||
<tr key={t.id} className={t.revoked ? styles.revokedRow : ''}>
|
||||
<td>{t.label}</td>
|
||||
<td className={styles.mono}>{t.tokenId}</td>
|
||||
<td className={styles.muted}>{fmt(t.lastUsedAt)}</td>
|
||||
<td className={styles.muted}>{fmt(t.expiresAt)}</td>
|
||||
<td className={styles.right}>
|
||||
{t.revoked ? <span className={styles.revoked}>revoked</span> : <button className={styles.danger} onClick={() => revoke(t.id)}>Revoke</button>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { display: flex; flex-direction: column; gap: $s-4; height: 100%; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 26px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
overflow: hidden;
|
||||
button {
|
||||
height: 32px; padding: 0 12px; border: none; background: var(--surface);
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
.export {
|
||||
height: 32px; padding: 0 12px; border: 1px solid var(--border); border-radius: $radius;
|
||||
background: var(--surface); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
&:hover:not(:disabled) { background: var(--surface-alt); border-color: var(--border-strong); }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
.exportIcon { display: inline-flex; color: #107c10; } // Excel green
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(240px, 1fr);
|
||||
gap: $s-3;
|
||||
overflow-x: auto;
|
||||
align-items: start;
|
||||
padding-bottom: $s-3;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
min-height: 120px;
|
||||
max-height: 100%;
|
||||
}
|
||||
.colHead {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 2px solid var(--col);
|
||||
border-radius: $radius-lg $radius-lg 0 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
.colTitleRow { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.colTitle { font-size: 12px; font-weight: 700; color: var(--text); }
|
||||
.colCount {
|
||||
min-width: 20px; text-align: center; font-size: 11px; font-weight: 700;
|
||||
padding: 1px 7px; border-radius: 999px; background: var(--col); color: #fff;
|
||||
}
|
||||
.colSla { display: block; margin-top: 3px; font-size: 10px; color: var(--text-dim); }
|
||||
|
||||
.colBody { padding: 9px; display: flex; flex-direction: column; gap: 9px; overflow-y: auto; }
|
||||
.colEmpty { font-size: 12px; color: var(--text-dim); text-align: center; padding: $s-4 0; }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: $radius-lg;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import BoardCard from '../../components/Board/BoardCard';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { getConfig } from '../../services/analytics.service';
|
||||
import { BOARD_COLUMNS, boardColumn, type ColumnKey } from '../../utils/board';
|
||||
import { exportTicketsXlsx } from '../../utils/excel.utils';
|
||||
import { ExcelIcon } from '../../components/icons';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import type { ForgeConfig } from '../../types/analytics.types';
|
||||
import styles from './Board.module.scss';
|
||||
|
||||
export default function Board({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [brand, setBrand] = useState('');
|
||||
const [market, setMarket] = useState('');
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const [view, setView] = useState<'board' | 'list'>('board');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true); setError(false);
|
||||
Promise.all([getTickets({ status: 'active', q: search || undefined }), getConfig().catch(() => ({}))])
|
||||
.then(([t, c]) => { if (alive) { setTickets(t); setCfg(c); } })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const colleagues = cfg.colleagues ?? [];
|
||||
const thresholds = cfg.insightsThresholds ?? {};
|
||||
|
||||
const distinct = (sel: (t: Ticket) => string | null) =>
|
||||
[...new Set(tickets.map(sel).filter((v): v is string => !!v))].sort();
|
||||
const brands = useMemo(() => distinct(t => t.brand), [tickets]);
|
||||
const markets = useMemo(() => distinct(t => t.market), [tickets]);
|
||||
const assignees = useMemo(() => distinct(t => t.assignedTo), [tickets]);
|
||||
|
||||
const filtered = useMemo(() => tickets.filter(t =>
|
||||
(!brand || t.brand === brand) && (!market || t.market === market) && (!assignee || t.assignedTo === assignee),
|
||||
), [tickets, brand, market, assignee]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const groups: Record<ColumnKey, Ticket[]> = { unassigned: [], open: [], hold: [], wip: [], replied: [], awaiting: [] };
|
||||
for (const t of filtered) groups[boardColumn(t, colleagues)].push(t);
|
||||
return groups;
|
||||
}, [filtered, colleagues]);
|
||||
|
||||
const brandColor = (b: string | null) => {
|
||||
if (!b) return undefined;
|
||||
return cfg.brandColors?.[b.toLowerCase().split(/[\s-]/)[0]];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active board" subtitle={`${filtered.length} of ${tickets.length} open tickets`}>
|
||||
<select className={styles.select} value={brand} onChange={e => setBrand(e.target.value)}>
|
||||
<option value="">All brands</option>{brands.map(b => <option key={b}>{b}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={market} onChange={e => setMarket(e.target.value)}>
|
||||
<option value="">All markets</option>{markets.map(m => <option key={m}>{m}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={assignee} onChange={e => setAssignee(e.target.value)}>
|
||||
<option value="">All PMs</option>{assignees.map(a => <option key={a}>{a}</option>)}
|
||||
</select>
|
||||
<div className={styles.toggle}>
|
||||
<button className={view === 'board' ? styles.on : ''} onClick={() => setView('board')}>Board</button>
|
||||
<button className={view === 'list' ? styles.on : ''} onClick={() => setView('list')}>List</button>
|
||||
</div>
|
||||
<button className={styles.export} onClick={() => exportTicketsXlsx(filtered, 'forge-active-tickets')} disabled={filtered.length === 0}
|
||||
title="Export the filtered tickets to Excel">
|
||||
<ExcelIcon size={15} />
|
||||
Excel
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{loading && <div className={styles.state}>Loading board…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load the board. Is the server running?</div>}
|
||||
|
||||
{!loading && !error && view === 'board' && (
|
||||
<div className={styles.board}>
|
||||
{BOARD_COLUMNS.map(col => {
|
||||
const items = columns[col.key];
|
||||
const thr = thresholds[col.slaKey];
|
||||
return (
|
||||
<section key={col.key} className={styles.column}>
|
||||
<header className={styles.colHead} style={{ '--col': col.color } as React.CSSProperties}>
|
||||
<div className={styles.colTitleRow}>
|
||||
<span className={styles.colTitle}>{col.label}</span>
|
||||
<span className={styles.colCount}>{items.length}</span>
|
||||
</div>
|
||||
{thr != null && <span className={styles.colSla}>{col.slaVerb(thr)}</span>}
|
||||
</header>
|
||||
<div className={styles.colBody}>
|
||||
{items.length === 0
|
||||
? <div className={styles.colEmpty}>No tickets</div>
|
||||
: items.map(t => <BoardCard key={t.number} ticket={t} color={col.color} brandColor={brandColor(t.brand)} onSelect={setSelected} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && view === 'list' && (
|
||||
<TicketTable tickets={filtered} variant="active" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.stat {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-4;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.statValue { font-size: 22px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.statLabel { margin-top: 2px; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import { formatCost, formatMinutes } from '../../utils/format.utils';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './Closed.module.scss';
|
||||
|
||||
export default function Closed({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [brandFilter, setBrandFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
getTickets({ status: 'closed', q: search || undefined })
|
||||
.then(t => { if (alive) setTickets(t); })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const brands = useMemo(
|
||||
() => [...new Set(tickets.map(t => t.brand).filter((b): b is string => !!b))].sort(),
|
||||
[tickets],
|
||||
);
|
||||
const filtered = useMemo(
|
||||
() => tickets.filter(t => !brandFilter || t.brand === brandFilter),
|
||||
[tickets, brandFilter],
|
||||
);
|
||||
|
||||
const totalCost = useMemo(
|
||||
() => filtered.reduce((sum, t) => sum + (t.finalCost ?? 0), 0),
|
||||
[filtered],
|
||||
);
|
||||
const avgTtfr = useMemo(() => {
|
||||
const vals = filtered.map(t => t.ttfrMinutes).filter((v): v is number => v != null);
|
||||
return vals.length ? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length) : null;
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Closed tickets" subtitle={`${filtered.length} fulfilled requests`}>
|
||||
<select className={styles.select} value={brandFilter} onChange={e => setBrandFilter(e.target.value)}>
|
||||
<option value="">All brands</option>
|
||||
{brands.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</PageHeader>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className={styles.summary}>
|
||||
<Stat label="Tickets" value={String(filtered.length)} />
|
||||
<Stat label="Total cost" value={formatCost(totalCost)} />
|
||||
<Stat label="Avg. time to first reply" value={formatMinutes(avgTtfr)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className={styles.state}>Loading tickets…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load tickets. Is the server running?</div>}
|
||||
{!loading && !error && filtered.length === 0 && <div className={styles.state}>No closed tickets found.</div>}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TicketTable tickets={filtered} variant="closed" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.select {
|
||||
height: 32px;
|
||||
padding: 0 28px 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
|
||||
&:hover { border-color: var(--border-strong); }
|
||||
&:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-bg); }
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import TicketTable from '../../components/TicketTable';
|
||||
import TicketDetailModal from '../../components/TicketDetailModal';
|
||||
import { getTickets } from '../../services/ticket.service';
|
||||
import type { Ticket } from '../../types/ticket.types';
|
||||
import styles from './Dashboard.module.scss';
|
||||
|
||||
export default function Dashboard({ search, refreshKey }: { search: string; refreshKey: number }) {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selected, setSelected] = useState<Ticket | null>(null);
|
||||
const [stateFilter, setStateFilter] = useState<string>('');
|
||||
const [groupFilter, setGroupFilter] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
getTickets({ status: 'active', q: search || undefined })
|
||||
.then(t => { if (alive) setTickets(t); })
|
||||
.catch(() => { if (alive) setError(true); })
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [search, refreshKey]);
|
||||
|
||||
const states = useMemo(() => distinct(tickets.map(t => t.state)), [tickets]);
|
||||
const groups = useMemo(() => distinct(tickets.map(t => t.assignmentGroup)), [tickets]);
|
||||
|
||||
const filtered = useMemo(() => tickets.filter(t =>
|
||||
(!stateFilter || t.state === stateFilter) &&
|
||||
(!groupFilter || t.assignmentGroup === groupFilter),
|
||||
), [tickets, stateFilter, groupFilter]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Active board" subtitle={`${filtered.length} of ${tickets.length} open tickets`}>
|
||||
<select className={styles.select} value={stateFilter} onChange={e => setStateFilter(e.target.value)}>
|
||||
<option value="">All states</option>
|
||||
{states.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className={styles.select} value={groupFilter} onChange={e => setGroupFilter(e.target.value)}>
|
||||
<option value="">All groups</option>
|
||||
{groups.map(g => <option key={g} value={g}>{shortGroup(g)}</option>)}
|
||||
</select>
|
||||
</PageHeader>
|
||||
|
||||
{loading && <div className={styles.state}>Loading tickets…</div>}
|
||||
{error && <div className={styles.stateError}>Failed to load tickets. Is the server running?</div>}
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className={styles.state}>No tickets match the current filters.</div>
|
||||
)}
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<TicketTable tickets={filtered} variant="active" onSelect={setSelected} />
|
||||
)}
|
||||
|
||||
{selected && <TicketDetailModal ticket={selected} onClose={() => setSelected(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function distinct(values: (string | null)[]): string[] {
|
||||
return [...new Set(values.filter((v): v is string => !!v))].sort();
|
||||
}
|
||||
function shortGroup(g: string): string {
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.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: 12px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
&:hover { background: var(--surface-alt); }
|
||||
}
|
||||
.cardTitle { font-size: 13px; 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: 340px; overflow-y: auto; }
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto auto;
|
||||
align-items: center;
|
||||
gap: $s-2;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.emptyRow { padding: 10px 14px; color: var(--text-dim); font-size: 12px; }
|
||||
.rowNum { display: inline-flex; align-items: center; gap: 3px; font-family: var(--mono); font-weight: 700; color: var(--primary); white-space: nowrap; }
|
||||
.rowDesc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.rowMeta { color: var(--text-muted); white-space: nowrap; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rowDays { font-weight: 600; color: var(--warning); font-variant-numeric: tabular-nums; }
|
||||
.rowCost { font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.pmCard {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.pmTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
th {
|
||||
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; color: var(--text-muted); padding: 10px 14px;
|
||||
background: var(--surface-alt); border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
}
|
||||
td { padding: 8px 14px; border-bottom: 1px solid var(--border); }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
}
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.alertBadge { font-weight: 700; padding: 1px 8px; border-radius: 999px; background: var(--danger-bg); color: var(--danger); }
|
||||
.risk { font-weight: 600; color: var(--danger); }
|
||||
|
||||
.state, .stateError { padding: $s-8; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import { ExternalIcon } 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);
|
||||
|
||||
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>
|
||||
|
||||
<h2 className={styles.h2}>Alert categories</h2>
|
||||
<div className={styles.grid}>
|
||||
{alertGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} showProblem />)}
|
||||
</div>
|
||||
|
||||
<h2 className={styles.h2}>Watch list</h2>
|
||||
<div className={styles.grid}>
|
||||
{infoGroups.map(g => <AlertCard key={g.key} g={g} open={open === g.key} onToggle={() => setOpen(open === g.key ? null : g.key)} />)}
|
||||
</div>
|
||||
|
||||
<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 AlertCard({ g, open, onToggle, showProblem }: { g: AlertGroup; open: boolean; onToggle: () => void; showProblem?: boolean }) {
|
||||
const shown = showProblem ? g.tickets.filter((_, i) => i < g.problematic || open) : g.tickets;
|
||||
return (
|
||||
<div className={`${styles.card} ${showProblem && g.problematic > 0 ? styles.cardHot : ''}`}>
|
||||
<button className={styles.cardHead} onClick={onToggle}>
|
||||
<span className={styles.cardTitle}>{g.label}</span>
|
||||
<span className={styles.counts}>
|
||||
{showProblem && <span className={styles.problem}>{g.problematic}</span>}
|
||||
<span className={styles.total}>{g.count}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className={styles.list}>
|
||||
{shown.length === 0 && <li className={styles.emptyRow}>No tickets</li>}
|
||||
{shown.map(t => <TicketRow key={t.number} t={t} />)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketRow({ t }: { t: InsightTicket }) {
|
||||
return (
|
||||
<li className={styles.row}>
|
||||
<a className={styles.rowNum} href={t.link ?? undefined} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>
|
||||
{t.number}{t.link && <ExternalIcon size={11} />}
|
||||
</a>
|
||||
<span className={styles.rowDesc} title={t.shortDesc}>{t.shortDesc}</span>
|
||||
<span className={styles.rowMeta}>{t.assignedTo ?? '—'}</span>
|
||||
{t.days != null && <span className={styles.rowDays}>{t.days}d</span>}
|
||||
{t.costGbp > 0 && <span className={styles.rowCost}>{money(t.costGbp)}</span>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; display: flex; flex-direction: column; gap: $s-4; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: $s-3;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 1000px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
.stack { display: flex; flex-direction: column; gap: $s-3; }
|
||||
|
||||
.revWrap { position: relative; }
|
||||
.revToggle {
|
||||
position: absolute; top: $s-5; right: 132px; z-index: 1;
|
||||
display: inline-flex; border: 1px solid var(--border); border-radius: $radius; overflow: hidden;
|
||||
button { height: 26px; padding: 0 10px; border: none; background: var(--surface); color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer;
|
||||
&.on { background: var(--primary); color: #fff; } }
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import KpiTile from '../../components/charts/KpiTile';
|
||||
import BarList from '../../components/charts/BarList';
|
||||
import GroupedBars from '../../components/charts/GroupedBars';
|
||||
import DonutChart from '../../components/charts/DonutChart';
|
||||
import ExpandBarList from '../../components/charts/ExpandBarList';
|
||||
import { getOverview, getConfig } from '../../services/analytics.service';
|
||||
import type { OverviewResponse, ForgeConfig, Bucket } from '../../types/analytics.types';
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
function money(n: number): string {
|
||||
if (n >= 1_000_000) return `£${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `£${(n / 1_000).toFixed(0)}k`;
|
||||
return `£${n}`;
|
||||
}
|
||||
|
||||
export default function Overview({ refreshKey }: { refreshKey: number }) {
|
||||
const [data, setData] = useState<OverviewResponse | null>(null);
|
||||
const [cfg, setCfg] = useState<ForgeConfig>({});
|
||||
const [error, setError] = useState(false);
|
||||
const [revMode, setRevMode] = useState<'cost' | 'tickets'>('cost');
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
Promise.all([getOverview(), getConfig().catch(() => ({}))])
|
||||
.then(([o, c]) => { if (alive) { setData(o); setCfg(c); } })
|
||||
.catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load analytics.</div>;
|
||||
if (!data) return <div className={styles.state}>Loading analytics…</div>;
|
||||
|
||||
const brandColor = (b: Bucket) => cfg.brandColors?.[b.key.toLowerCase().split(/[\s-]/)[0]] ?? 'var(--primary)';
|
||||
const totalRevenue = data.revenueByMonth.reduce((s, m) => s + m.count, 0);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Overall statistics" subtitle={`${data.totals.total} tickets · ${data.totals.closed} closed`} />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<KpiTile label="Total tickets" value={data.totals.total} />
|
||||
<KpiTile label="Active" value={data.totals.active} tone="warning" />
|
||||
<KpiTile label="Closed" value={data.totals.closed} tone="success" />
|
||||
<KpiTile label="Delivered revenue" value={money(totalRevenue)} tone="primary" />
|
||||
<KpiTile label="Median lifetime" value={data.lifetime.medianDays != null ? `${data.lifetime.medianDays}d` : '—'} tone="neutral" hint={data.lifetime.avgDays != null ? `avg ${data.lifetime.avgDays}d` : undefined} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<GroupedBars title="Opened per month" points={data.openedByMonth} />
|
||||
<GroupedBars title="Closed per month" points={data.closedByMonth} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<div className={styles.revWrap}>
|
||||
<div className={styles.revToggle}>
|
||||
<button className={revMode === 'cost' ? styles.on : ''} onClick={() => setRevMode('cost')}>Cost</button>
|
||||
<button className={revMode === 'tickets' ? styles.on : ''} onClick={() => setRevMode('tickets')}>Tickets</button>
|
||||
</div>
|
||||
<GroupedBars
|
||||
title={revMode === 'cost' ? 'Revenue per month (closed)' : 'Tickets closed per month'}
|
||||
points={revMode === 'cost' ? data.revenueByMonth : data.closedByMonth}
|
||||
format={revMode === 'cost' ? money : undefined}
|
||||
defaultMode="line"
|
||||
/>
|
||||
</div>
|
||||
<DonutChart title="Ticket share by requester" data={data.byRequesterShare} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<ExpandBarList title="By brand → market" data={data.byBrand} colorFor={brandColor} labelWidth={140} />
|
||||
<ExpandBarList title="By requester → brand" data={data.byRequester} labelWidth={200} />
|
||||
</div>
|
||||
|
||||
<div className={styles.row2}>
|
||||
<BarList title="By market" data={data.byMarket} labelWidth={130} />
|
||||
<div className={styles.stack}>
|
||||
<BarList title="By business unit" data={data.byBusinessUnit} labelWidth={130} />
|
||||
<BarList title="Ticket lifetime at close" data={data.lifetime.buckets} labelWidth={100}
|
||||
colorFor={(_, i) => ['#16a34a', '#22c55e', '#f59e0b', '#ea580c', '#dc2626', '#991b1b'][i] ?? 'var(--primary)'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
.grid { display: flex; flex-direction: column; gap: $s-3; }
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import SlaHeatmap from '../../components/charts/SlaHeatmap';
|
||||
import { getSla } from '../../services/analytics.service';
|
||||
import type { SlaMetric } from '../../types/analytics.types';
|
||||
import styles from './SlaKpi.module.scss';
|
||||
|
||||
export default function SlaKpi({ refreshKey }: { refreshKey: number }) {
|
||||
const [metrics, setMetrics] = useState<SlaMetric[] | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
getSla()
|
||||
.then(r => { if (alive) setMetrics(r.metrics); })
|
||||
.catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load SLA metrics.</div>;
|
||||
if (!metrics) return <div className={styles.state}>Loading SLA metrics…</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="PM KPIs — SLA" subtitle="Per-PM × ticket-size averages vs configured norms (green = on target, red = over)" />
|
||||
<div className={styles.grid}>
|
||||
{metrics.map(m => <SlaHeatmap key={m.key} metric={m} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@use '../../styles/variables' as *;
|
||||
|
||||
.page { max-width: none; }
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $s-3;
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
.kpi {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.kpiAccent {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-h));
|
||||
border-color: transparent;
|
||||
.kpiValue, .kpiLabel { color: #fff; }
|
||||
.kpiLabel { opacity: 0.85; }
|
||||
}
|
||||
.kpiValue { font-size: 30px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1; }
|
||||
.kpiLabel { margin-top: 6px; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $s-3;
|
||||
}
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: $radius-lg;
|
||||
padding: $s-5;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.cardWide { grid-column: 1 / -1; }
|
||||
.cardTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: $s-4;
|
||||
}
|
||||
|
||||
.bars { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
||||
.bar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr 40px;
|
||||
align-items: center;
|
||||
gap: $s-3;
|
||||
}
|
||||
.barLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.barTrack {
|
||||
height: 20px;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.barFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-h));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.barValue {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.state, .stateError {
|
||||
padding: $s-8;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stateError { color: var(--danger); }
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import { getStats } from '../../services/ticket.service';
|
||||
import type { StatsResponse } from '../../types/ticket.types';
|
||||
import styles from './Stats.module.scss';
|
||||
|
||||
export default function Stats({ refreshKey }: { refreshKey: number }) {
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setError(false);
|
||||
getStats().then(s => { if (alive) setStats(s); }).catch(() => { if (alive) setError(true); });
|
||||
return () => { alive = false; };
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) return <div className={styles.stateError}>Failed to load statistics.</div>;
|
||||
if (!stats) return <div className={styles.state}>Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<PageHeader title="Statistics" subtitle="Distribution across the active ticket board" />
|
||||
|
||||
<div className={styles.kpis}>
|
||||
<Kpi label="Total tickets" value={stats.total} accent />
|
||||
<Kpi label="Active" value={stats.active} />
|
||||
<Kpi label="Closed" value={stats.closed} />
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<BarCard title="By state" data={stats.byState.map(d => ({ label: d.state, count: d.count }))} />
|
||||
<BarCard title="By assignment group" data={stats.byGroup.map(d => ({ label: shortGroup(d.group), count: d.count }))} />
|
||||
<BarCard title="Top assignees" data={stats.byAssignee.map(d => ({ label: d.assignee, count: d.count }))} wide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||
return (
|
||||
<div className={`${styles.kpi} ${accent ? styles.kpiAccent : ''}`}>
|
||||
<div className={styles.kpiValue}>{value}</div>
|
||||
<div className={styles.kpiLabel}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarCard({ title, data, wide }: { title: string; data: { label: string; count: number }[]; wide?: boolean }) {
|
||||
const max = Math.max(1, ...data.map(d => d.count));
|
||||
return (
|
||||
<div className={`${styles.card} ${wide ? styles.cardWide : ''}`}>
|
||||
<h3 className={styles.cardTitle}>{title}</h3>
|
||||
<ul className={styles.bars}>
|
||||
{data.map((d, i) => (
|
||||
<li key={`${d.label}-${i}`} className={styles.bar}>
|
||||
<span className={styles.barLabel} title={d.label}>{d.label}</span>
|
||||
<span className={styles.barTrack}>
|
||||
<span className={styles.barFill} style={{ width: `${(d.count / max) * 100}%` }} />
|
||||
</span>
|
||||
<span className={styles.barValue}>{d.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shortGroup(g: string): string {
|
||||
return g.replace(/^L3\s+/, '').replace(/\s+Marketing Web Presence$/i, '');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { apiGet, apiPost, apiFetch } from './api.service';
|
||||
import type { Role } from './auth.service';
|
||||
|
||||
export interface AppUser { id: number; username: string; role: Role; createdAt: string | null; lastLoginAt: string | null; }
|
||||
export interface TokenInfo { id: number; tokenId: string; label: string; createdAt: string | null; lastUsedAt: string | null; expiresAt: string | null; revoked: boolean; }
|
||||
|
||||
export async function listUsers(): Promise<AppUser[]> {
|
||||
return (await apiGet<{ users: AppUser[] }>('/api/users')).users;
|
||||
}
|
||||
export function createUser(username: string, password: string, role: Role): Promise<{ user?: AppUser; error?: string; message?: string }> {
|
||||
return apiPost('/api/users', { username, password, role }, { allowAuthErrorBody: true });
|
||||
}
|
||||
export function deleteUser(username: string): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listTokens(): Promise<TokenInfo[]> {
|
||||
return (await apiGet<{ tokens: TokenInfo[] }>('/api/tokens')).tokens;
|
||||
}
|
||||
export function createToken(label: string): Promise<{ token: string; label: string }> {
|
||||
return apiPost('/api/tokens', { label });
|
||||
}
|
||||
export function revokeToken(id: number): Promise<{ success: boolean }> {
|
||||
return apiPost(`/api/tokens/${id}/revoke`, {});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiGet } from './api.service';
|
||||
import type { OverviewResponse, SlaMetric, ForgeConfig, InsightsResponse, JiraDurationsResponse } from '../types/analytics.types';
|
||||
|
||||
export function getInsights(): Promise<InsightsResponse> {
|
||||
return apiGet<InsightsResponse>('/api/insights');
|
||||
}
|
||||
|
||||
export function getJiraDurations(): Promise<JiraDurationsResponse> {
|
||||
return apiGet<JiraDurationsResponse>('/api/analytics/jira-durations');
|
||||
}
|
||||
|
||||
export function getOverview(): Promise<OverviewResponse> {
|
||||
return apiGet<OverviewResponse>('/api/analytics/overview');
|
||||
}
|
||||
|
||||
export function getSla(): Promise<{ metrics: SlaMetric[] }> {
|
||||
return apiGet<{ metrics: SlaMetric[] }>('/api/analytics/sla');
|
||||
}
|
||||
|
||||
export function getConfig(): Promise<ForgeConfig> {
|
||||
return apiGet<ForgeConfig>('/api/config');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Fired when any API call returns 401 (session expired/absent). App registers it
|
||||
// to drop to the login screen.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
interface ApiOptions extends RequestInit {
|
||||
// When true, a 401 body is returned to the caller instead of throwing +
|
||||
// firing the logout hook. Used by /login to surface { success, error }.
|
||||
allowAuthErrorBody?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(url: string, options?: ApiOptions): Promise<T> {
|
||||
const { allowAuthErrorBody, ...init } = options ?? {};
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
if (!allowAuthErrorBody) { onUnauthorized?.(); throw new Error('unauthenticated'); }
|
||||
// else fall through and return the { success, error } body
|
||||
} else if (res.status === 400 || res.status === 409) {
|
||||
// Validation / conflict responses carry a { error, message } body the caller reads.
|
||||
} else {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function apiGet<T>(url: string): Promise<T> {
|
||||
return apiFetch<T>(url);
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, body: unknown, options?: ApiOptions): Promise<T> {
|
||||
return apiFetch<T>(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { apiGet, apiPost } from './api.service';
|
||||
|
||||
export type Role = 'viewer' | 'pm' | 'lead' | 'admin';
|
||||
|
||||
export interface CurrentUser {
|
||||
username: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const ORDER: Role[] = ['viewer', 'pm', 'lead', 'admin'];
|
||||
export function roleAtLeast(role: Role | undefined, min: Role): boolean {
|
||||
return !!role && ORDER.indexOf(role) >= ORDER.indexOf(min);
|
||||
}
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
viewer: 'Viewer', pm: 'PM', lead: 'Project Leadership', admin: 'Admin',
|
||||
};
|
||||
|
||||
export async function getMe(): Promise<CurrentUser | null> {
|
||||
const data = await apiGet<{ user: CurrentUser | null }>('/api/me');
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export function login(username: string, password: string): Promise<{ success: boolean; user?: CurrentUser; error?: string }> {
|
||||
return apiPost('/login', { username, password }, { allowAuthErrorBody: true });
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ success: boolean }> {
|
||||
return apiPost('/logout', {});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { apiGet } from './api.service';
|
||||
import type { Ticket, TicketFilters, StatsResponse } from '../types/ticket.types';
|
||||
|
||||
export async function getTickets(filters: TicketFilters = {}): Promise<Ticket[]> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(filters)) if (v) qs.set(k, String(v));
|
||||
const data = await apiGet<{ tickets: Ticket[] }>(`/api/tickets?${qs.toString()}`);
|
||||
return data.tickets;
|
||||
}
|
||||
|
||||
export function getTicket(number: string): Promise<Ticket> {
|
||||
return apiGet<Ticket>(`/api/tickets/${encodeURIComponent(number)}`);
|
||||
}
|
||||
|
||||
export function getStats(): Promise<StatsResponse> {
|
||||
return apiGet<StatsResponse>('/api/stats');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// SCSS build-time tokens for use inside .module.scss files.
|
||||
// Runtime CSS custom properties live in globals.scss.
|
||||
|
||||
$radius: 6px;
|
||||
$radius-lg: 10px;
|
||||
|
||||
$s-1: 4px;
|
||||
$s-2: 8px;
|
||||
$s-3: 12px;
|
||||
$s-4: 16px;
|
||||
$s-5: 20px;
|
||||
$s-6: 24px;
|
||||
$s-8: 32px;
|
||||
|
||||
$font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
$font-mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
|
||||
$transition: 0.15s ease;
|
||||
@@ -0,0 +1,68 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-h: #4f46e5;
|
||||
--primary-bg: rgba(99, 102, 241, 0.10);
|
||||
|
||||
--success: #16a34a;
|
||||
--success-bg: rgba(22, 163, 74, 0.10);
|
||||
--warning: #ea580c;
|
||||
--warning-bg: rgba(234, 88, 12, 0.10);
|
||||
--danger: #dc2626;
|
||||
--danger-bg: rgba(220, 38, 38, 0.10);
|
||||
--neutral: #64748b;
|
||||
--neutral-bg: rgba(100, 116, 139, 0.12);
|
||||
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--border-strong:#cbd5e1;
|
||||
--text: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-dim: #94a3b8;
|
||||
|
||||
--row-hover: #f8fafc;
|
||||
|
||||
--sidebar: #0f172a;
|
||||
--sidebar-alt: #1e293b;
|
||||
--sidebar-text: #cbd5e1;
|
||||
--sidebar-dim: #64748b;
|
||||
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--mono: 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
--shadow-lg: 0 20px 50px rgba(15, 23, 42, 0.22);
|
||||
|
||||
--topbar-h: 56px;
|
||||
--sidebar-w: 236px;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
@@ -0,0 +1,59 @@
|
||||
export interface Bucket { key: string; label: string; count: number; }
|
||||
export interface NestedBucket extends Bucket { children: Bucket[]; }
|
||||
export interface MonthPoint { month: string; count: number; }
|
||||
|
||||
export interface OverviewResponse {
|
||||
totals: { total: number; active: number; closed: number };
|
||||
openedByMonth: MonthPoint[];
|
||||
closedByMonth: MonthPoint[];
|
||||
revenueByMonth: MonthPoint[];
|
||||
byState: Bucket[];
|
||||
byBrand: NestedBucket[];
|
||||
byMarket: Bucket[];
|
||||
byBusinessUnit: Bucket[];
|
||||
byRequester: NestedBucket[];
|
||||
byRequesterShare: Bucket[];
|
||||
lifetime: { buckets: Bucket[]; medianDays: number | null; avgDays: number | null; closed: number };
|
||||
}
|
||||
|
||||
export interface SlaCell { avgDays: number | null; count: number; onTime: number; onTimePct: number | null; }
|
||||
export interface SlaMetric {
|
||||
key: string;
|
||||
title: string;
|
||||
unit: 'days' | 'hours';
|
||||
norms: Record<string, number>;
|
||||
pms: string[];
|
||||
sizes: string[];
|
||||
grid: Record<string, Record<string, SlaCell>>;
|
||||
totals: Record<string, SlaCell>;
|
||||
}
|
||||
|
||||
export interface InsightTicket {
|
||||
number: string; shortDesc: string; assignedTo: string | null;
|
||||
brand: string | null; market: string | null; state: string;
|
||||
days: number | null; link: string | null; jiraStatus: string | null; costGbp: number;
|
||||
}
|
||||
export interface AlertGroup { key: string; label: string; count: number; problematic: number; tickets: InsightTicket[]; }
|
||||
export interface PmRow { pm: string; tickets: number; revenue: number; alerts: number; revAtRisk: number; }
|
||||
export interface InsightsResponse {
|
||||
totalAlerts: number;
|
||||
revAtRisk: number;
|
||||
groups: AlertGroup[];
|
||||
waitingPo: { count: number; revenue: number; levels: { l1: number; l2: number; l3: number } };
|
||||
pms: PmRow[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface JiraDuration { status: string; avgDays: number; avgHours: number; count: number; }
|
||||
export interface JiraDurationsResponse { order: string[]; rows: JiraDuration[]; }
|
||||
|
||||
export interface ForgeConfig {
|
||||
brandColors?: Record<string, string>;
|
||||
fxRates?: Record<string, number>;
|
||||
displayCurrency?: string;
|
||||
norms?: Record<string, Record<string, number>>;
|
||||
sizeThresholds?: Record<string, number>;
|
||||
insightsThresholds?: Record<string, number>;
|
||||
colleagues?: string[];
|
||||
latamAssignees?: string[];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type TicketStatus = 'active' | 'closed';
|
||||
|
||||
export interface ActivityEntry {
|
||||
kind: 'comment' | 'worknote' | string;
|
||||
t: string;
|
||||
who: string;
|
||||
}
|
||||
|
||||
export interface JiraInfo {
|
||||
status?: string;
|
||||
statusChangedAt?: string;
|
||||
key?: string | null;
|
||||
url?: string | null;
|
||||
assignee?: string | null;
|
||||
statusDurations?: Record<string, number>;
|
||||
movements?: { at: string; who?: string; from?: string; to?: string }[];
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
number: string;
|
||||
status: TicketStatus;
|
||||
state: string;
|
||||
shortDesc: string;
|
||||
assignedTo: string | null;
|
||||
assignmentGroup: string | null;
|
||||
brand: string | null;
|
||||
market: string | null;
|
||||
businessUnit: string | null;
|
||||
requestedFor: string | null;
|
||||
openedBy: string | null;
|
||||
openedAt: string | null;
|
||||
openedDate: string | null;
|
||||
closedDate: string | null;
|
||||
toDoAt: string | null;
|
||||
inUatAt: string | null;
|
||||
jiraKey: string | null;
|
||||
currencyCode: string | null;
|
||||
ticketYear: number | null;
|
||||
size: string | null;
|
||||
poNumber: string | null;
|
||||
invoiced: string | null;
|
||||
dueDate: string | null;
|
||||
stateChangedAt: string | null;
|
||||
stateChangedBy: string | null;
|
||||
lastActivityAt: string | null;
|
||||
lastActivityBy: string | null;
|
||||
lastComment: string | null;
|
||||
description: string | null;
|
||||
link: string | null;
|
||||
finalCost: number | null;
|
||||
ttfrMinutes: number | null;
|
||||
clientRespMinutes: number | null;
|
||||
fulfillmentDate: string | null;
|
||||
firstReplyAt: string | null;
|
||||
firstAssignedDate: string | null;
|
||||
updatedAt: string | null;
|
||||
jira: JiraInfo | null;
|
||||
activity: ActivityEntry[];
|
||||
}
|
||||
|
||||
export interface TicketFilters {
|
||||
status?: TicketStatus;
|
||||
state?: string;
|
||||
group?: string;
|
||||
assignee?: string;
|
||||
q?: string;
|
||||
}
|
||||
|
||||
export interface StatsResponse {
|
||||
total: number;
|
||||
active: number;
|
||||
closed: number;
|
||||
byState: { state: string; count: number }[];
|
||||
byGroup: { group: string; count: number }[];
|
||||
byAssignee: { assignee: string; count: number }[];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Ticket } from '../types/ticket.types';
|
||||
|
||||
// The initial app's kanban column model. A ticket's column is derived from its
|
||||
// ServiceNow state, with one pseudo-status: a Work-In-Progress ticket whose last
|
||||
// activity was by the client (not a colleague) becomes "Customer replied".
|
||||
|
||||
export type ColumnKey =
|
||||
| 'unassigned' | 'open' | 'hold' | 'wip' | 'replied' | 'awaiting';
|
||||
|
||||
export interface ColumnDef {
|
||||
key: ColumnKey;
|
||||
label: string;
|
||||
color: string;
|
||||
slaKey: string; // key into insightsThresholds
|
||||
slaVerb: (n: number) => string;
|
||||
}
|
||||
|
||||
export const BOARD_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'unassigned', label: 'Unassigned', color: '#78716c', slaKey: 'unassigned', slaVerb: n => `assign within ${n}d` },
|
||||
{ key: 'open', label: 'Open / Assigned', color: '#16a34a', slaKey: 'assigned', slaVerb: n => `start within ${n}d` },
|
||||
{ key: 'hold', label: 'On Hold', color: '#334155', slaKey: 'hold', slaVerb: n => `max ${n}d on hold` },
|
||||
{ key: 'wip', label: 'Work In Progress', color: '#6366f1', slaKey: 'wip', slaVerb: n => `max ${n}d in progress` },
|
||||
{ key: 'replied', label: 'Customer replied', color: '#dc2626', slaKey: 'customerReplied', slaVerb: n => `reply within ${n}d` },
|
||||
{ key: 'awaiting', label: 'Awaiting Customer Info', color: '#ea580c', slaKey: 'awaiting', slaVerb: n => `escalate after ${n}d` },
|
||||
];
|
||||
|
||||
const isColleague = (name: string | null, colleagues: string[]): boolean => {
|
||||
if (!name) return false;
|
||||
const n = name.trim().toLowerCase();
|
||||
return colleagues.some(c => c.trim().toLowerCase() === n);
|
||||
};
|
||||
|
||||
// A WIP ticket where the last touch was by the client (not us) → "Customer replied".
|
||||
function isAwaitingAgency(t: Ticket, colleagues: string[]): boolean {
|
||||
if (!/progress/i.test(t.state)) return false;
|
||||
if (colleagues.length > 0) return !isColleague(t.lastActivityBy, colleagues);
|
||||
// fallback: last activity by the requester
|
||||
return !!t.lastActivityBy && t.lastActivityBy === t.requestedFor;
|
||||
}
|
||||
|
||||
export function boardColumn(t: Ticket, colleagues: string[]): ColumnKey {
|
||||
const s = t.state.toLowerCase();
|
||||
if (/on.?hold/.test(s)) return 'hold';
|
||||
if (/progress/.test(s)) return isAwaitingAgency(t, colleagues) ? 'replied' : 'wip';
|
||||
if (/awaiting/.test(s)) return 'awaiting';
|
||||
if (!t.assignedTo) return 'unassigned';
|
||||
if (/open|new|assigned/.test(s)) return 'open';
|
||||
return 'open';
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import writeXlsxFile from 'write-excel-file';
|
||||
import { unzipSync, zipSync, strToU8, strFromU8 } from 'fflate';
|
||||
import type { Ticket } from '../types/ticket.types';
|
||||
import { parseDate } from './format.utils';
|
||||
|
||||
function daysSince(v: string | null): number | null {
|
||||
const d = parseDate(v);
|
||||
return d ? Math.floor((Date.now() - d.getTime()) / 86_400_000) : null;
|
||||
}
|
||||
// Short local date (avoids ugly raw SNOW timestamps).
|
||||
function shortDate(v: string | null): string {
|
||||
const d = parseDate(v);
|
||||
return d ? d.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' }) : '';
|
||||
}
|
||||
function xmlEsc(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// --- palette (FORGE tokens) ------------------------------------------------
|
||||
const BORDER = '#E2E8F0';
|
||||
const HEADER_BG = '#6366F1'; // --primary indigo
|
||||
const STRIPE = '#F4F6FE'; // very light indigo — zebra rows
|
||||
const WHITE = '#FFFFFF';
|
||||
const LINK = '#2563EB';
|
||||
const ROW_H = 26;
|
||||
|
||||
type Row = Ticket & { __row: number };
|
||||
type Align = 'left' | 'right' | 'center';
|
||||
|
||||
interface Col {
|
||||
column: string;
|
||||
type: typeof String | typeof Number;
|
||||
width: number;
|
||||
align?: Align;
|
||||
wrap?: boolean;
|
||||
format?: string;
|
||||
color?: string;
|
||||
value: (t: Row) => string | number | null;
|
||||
}
|
||||
|
||||
const COLS: Col[] = [
|
||||
// Number is plain text (blue); a REAL Excel hyperlink is injected in post-processing
|
||||
// (a HYPERLINK() formula would break in `;`-separator Excel locales → #VALUE!).
|
||||
{ column: 'Number', type: String, width: 18, color: LINK, value: t => t.number },
|
||||
{ column: 'State', type: String, width: 22, value: t => t.state },
|
||||
{ column: 'Assignee', type: String, width: 24, value: t => t.assignedTo ?? '' },
|
||||
{ column: 'Short description', type: String, width: 54, wrap: true, value: t => t.shortDesc },
|
||||
{ column: 'Brand', type: String, width: 16, value: t => t.brand ?? '' },
|
||||
{ column: 'Market', type: String, width: 16, value: t => t.market ?? '' },
|
||||
{ column: 'Requester', type: String, width: 24, value: t => t.requestedFor ?? '' },
|
||||
{ column: 'Group', type: String, width: 32, value: t => t.assignmentGroup ?? '' },
|
||||
{ column: 'Lifetime (d)', type: Number, width: 13, align: 'right', value: t => daysSince(t.openedAt) },
|
||||
{ column: 'In state (d)', type: Number, width: 13, align: 'right', value: t => daysSince(t.stateChangedAt) },
|
||||
{ column: 'Last activity', type: String, width: 15, align: 'center', value: t => shortDate(t.lastActivityAt) },
|
||||
{ column: 'Jira', type: String, width: 15, value: t => t.jira?.status ?? '' },
|
||||
{ column: 'Jira key', type: String, width: 13, value: t => t.jiraKey ?? '' },
|
||||
{ column: 'Cost', type: Number, width: 12, align: 'right', format: '#,##0', value: t => t.finalCost },
|
||||
{ column: 'Ccy', type: String, width: 8, align: 'center', value: t => t.currencyCode ?? '' },
|
||||
{ column: 'PO', type: String, width: 16, value: t => t.poNumber ?? '' },
|
||||
];
|
||||
|
||||
function colLetter(n: number): string { // 1 → A, 27 → AA
|
||||
let s = '';
|
||||
while (n > 0) { const r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = Math.floor((n - 1) / 26); }
|
||||
return s;
|
||||
}
|
||||
|
||||
// Post-process the .xlsx: add an <autoFilter> across the header and REAL Excel
|
||||
// hyperlinks on the Number column (column A), by editing the sheet XML + its
|
||||
// relationships and re-zipping. Locale-independent (no HYPERLINK formula).
|
||||
function finalizeXlsx(bytes: Uint8Array, data: Row[]): Uint8Array {
|
||||
const files = unzipSync(bytes);
|
||||
const sheet = 'xl/worksheets/sheet1.xml';
|
||||
const relsPath = 'xl/worksheets/_rels/sheet1.xml.rels';
|
||||
if (!files[sheet]) return bytes;
|
||||
|
||||
let xml = strFromU8(files[sheet]);
|
||||
let rels = files[relsPath]
|
||||
? strFromU8(files[relsPath])
|
||||
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>';
|
||||
|
||||
let maxId = 0;
|
||||
for (const m of rels.matchAll(/Id="rId(\d+)"/g)) maxId = Math.max(maxId, Number(m[1]));
|
||||
|
||||
const relEntries: string[] = [];
|
||||
const hlEntries: string[] = [];
|
||||
data.forEach((t, i) => {
|
||||
if (!t.link) return;
|
||||
const id = `rId${++maxId}`;
|
||||
relEntries.push(`<Relationship Id="${id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="${xmlEsc(t.link)}" TargetMode="External"/>`);
|
||||
hlEntries.push(`<hyperlink ref="A${i + 2}" r:id="${id}"/>`); // row 1 = header
|
||||
});
|
||||
|
||||
const filterRef = `A1:${colLetter(COLS.length)}${data.length + 1}`;
|
||||
let inject = `<autoFilter ref="${filterRef}"/>`; // after </sheetData>
|
||||
if (hlEntries.length) inject += `<hyperlinks>${hlEntries.join('')}</hyperlinks>`; // after autoFilter
|
||||
if (!xml.includes('</sheetData>')) return bytes;
|
||||
xml = xml.replace('</sheetData>', `</sheetData>${inject}`);
|
||||
files[sheet] = strToU8(xml);
|
||||
|
||||
if (relEntries.length) {
|
||||
rels = rels.replace('</Relationships>', `${relEntries.join('')}</Relationships>`);
|
||||
files[relsPath] = strToU8(rels);
|
||||
}
|
||||
return zipSync(files);
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, fileName: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = fileName;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function datedName(base: string): string {
|
||||
const d = new Date();
|
||||
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
return `${base}-${ymd}.xlsx`;
|
||||
}
|
||||
|
||||
// Export the given (already-filtered) tickets to a polished, filterable .xlsx with
|
||||
// clickable ticket numbers. `baseName` gets the date appended: base-YYYY-MM-DD.xlsx.
|
||||
export async function exportTicketsXlsx(tickets: Ticket[], baseName = 'forge-tickets'): Promise<void> {
|
||||
const data: Row[] = tickets.map((t, i) => ({ ...t, __row: i }));
|
||||
|
||||
const schema = COLS.map(c => ({
|
||||
column: c.column,
|
||||
type: c.type,
|
||||
value: c.value,
|
||||
width: c.width,
|
||||
format: c.format,
|
||||
getCellStyle: (r: Row) => ({
|
||||
align: c.align ?? 'left',
|
||||
alignVertical: 'center' as const,
|
||||
wrap: c.wrap ?? false,
|
||||
color: c.color,
|
||||
fontSize: 11,
|
||||
height: ROW_H,
|
||||
borderColor: BORDER,
|
||||
borderStyle: 'thin' as const,
|
||||
backgroundColor: r.__row % 2 === 1 ? STRIPE : WHITE,
|
||||
}),
|
||||
}));
|
||||
|
||||
// No fileName → Blob back, so we can post-process it.
|
||||
const blob = await writeXlsxFile(data, {
|
||||
schema,
|
||||
fontFamily: 'Calibri',
|
||||
fontSize: 11,
|
||||
headerStyle: {
|
||||
backgroundColor: HEADER_BG, color: '#FFFFFF', fontWeight: 'bold',
|
||||
align: 'center', alignVertical: 'center', height: 30,
|
||||
borderColor: HEADER_BG, borderStyle: 'thin',
|
||||
},
|
||||
stickyRowsCount: 1,
|
||||
}) as Blob;
|
||||
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const finalized = finalizeXlsx(bytes, data);
|
||||
let out = blob;
|
||||
if (finalized !== bytes) {
|
||||
const ab = finalized.buffer.slice(finalized.byteOffset, finalized.byteOffset + finalized.byteLength) as ArrayBuffer;
|
||||
out = new Blob([ab], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
}
|
||||
triggerDownload(out, datedName(baseName));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseDate, formatMinutes, formatCost, initials } from './format.utils';
|
||||
|
||||
describe('parseDate', () => {
|
||||
it('parses naive SNOW timestamps (space separator)', () => {
|
||||
expect(parseDate('2026-08-25 12:58:06')?.getFullYear()).toBe(2026);
|
||||
});
|
||||
it('parses ISO timestamps', () => {
|
||||
expect(parseDate('2024-09-16T05:50:16.000Z')?.getUTCMonth()).toBe(8);
|
||||
});
|
||||
it('returns null for empty/invalid', () => {
|
||||
expect(parseDate('')).toBeNull();
|
||||
expect(parseDate('nope')).toBeNull();
|
||||
expect(parseDate(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMinutes', () => {
|
||||
it('scales minutes → hours → days', () => {
|
||||
expect(formatMinutes(45)).toBe('45m');
|
||||
expect(formatMinutes(90)).toBe('1.5h');
|
||||
expect(formatMinutes(2880)).toBe('2.0d');
|
||||
expect(formatMinutes(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCost', () => {
|
||||
it('formats euros, dashes null', () => {
|
||||
expect(formatCost(150)).toBe('€150');
|
||||
expect(formatCost(null)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('initials', () => {
|
||||
it('strips (Inactive) suffixes and takes first+last', () => {
|
||||
expect(initials('Alesya Prolagayeva')).toBe('AP');
|
||||
expect(initials('Levon Mkrtchyan (Inactive)')).toBe('LM');
|
||||
expect(initials(null)).toBe('?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Parse a ServiceNow timestamp. The archives mix naive local strings
|
||||
// ("2026-08-25 12:58:06") and ISO ("2024-09-16T05:50:16.000Z"); both parse
|
||||
// with a normalizing space→T for the naive form.
|
||||
export function parseDate(v: string | null | undefined): Date | null {
|
||||
if (typeof v !== 'string' || !v) return null;
|
||||
const iso = v.includes('T') ? v : v.replace(' ', 'T');
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
export function formatDate(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatDateTime(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// Relative "3d ago" style label, coarse-grained.
|
||||
export function timeAgo(v: string | null | undefined): string {
|
||||
const d = parseDate(v);
|
||||
if (!d) return '—';
|
||||
const secs = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (secs < 60) return 'just now';
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 30) return `${days}d ago`;
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return `${months}mo ago`;
|
||||
return `${Math.floor(months / 12)}y ago`;
|
||||
}
|
||||
|
||||
export function formatMinutes(mins: number | null): string {
|
||||
if (mins == null) return '—';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = mins / 60;
|
||||
if (hrs < 24) return `${hrs.toFixed(1)}h`;
|
||||
return `${(hrs / 24).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
export function formatCost(v: number | null): string {
|
||||
if (v == null) return '—';
|
||||
return `€${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
export function initials(name: string | null | undefined): string {
|
||||
if (!name) return '?';
|
||||
const parts = name.replace(/\(.*?\)/g, '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
return (parts[0][0] + (parts[1]?.[0] ?? '')).toUpperCase();
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
Reference in New Issue
Block a user