style: dark slate theme matching the original SPA's look

Pure styling sweep — no behaviour changes, no new dependencies, all
data-tutorial-id selectors preserved. typecheck / lint / build clean.
Main entry 16.36 KB gz (was 12.85), recharts unchanged at 154.57 KB gz.

Foundations:
- body bg-slate-950 text-slate-100, color-scheme:dark, Apple-system font.
- .btn-primary indigo, .btn-secondary slate-700, .card slate-900 +
  slate-800 border, .input slate-800/600, .label slate-400 uppercase.

Top chrome:
- Navbar rewritten as a two-row header: title row with brand + dynamic
  subtitle (filename + row count once a timelog is loaded), then the
  three coloured upload pills inline, then user identity + Sign-out text
  link. Tab row hangs off `border-b border-slate-800` with the
  original's rounded-top "tab" look — active `bg-slate-800 text-white
  border-t border-x border-slate-700`, inactive `text-slate-400`.
- HeaderUploads now matches the original: indigo Time Log pill, emerald
  Deliverable when loaded, violet Project Summary when loaded; neutral
  slate-700 surfaces when empty. Re-uploading replaces (no Clear
  button). Errors collapse to a small ⚠ marker so the header height
  doesn't jump.
- StatsBar: bg-slate-900/50 strip below the navbar with five stacked
  stats (label slate-400 / value white).

Filter rail:
- bg-slate-900/30 strip with stacked-label fields and slate-800/600
  inputs. Native <select multiple> capped to h-20 for the brand/division/
  hub/userRole multiselects (closer to the original's selects than our
  prior chip pattern). Reset is a slate-400 text link, pushed right.
  Filter blocks only render when their option list is populated.

Charts (all six):
- CartesianGrid stroke #334155, axis ticks #94a3b8 on #475569 axis lines.
- Tooltip contentStyle: slate-800 surface + slate-700 border + slate-200
  text, indigo cursor highlight. Tooltip filterNull preserved on Project
  Load.
- "Available"/"Idle" greys swapped to slate-600 so they're visible on
  dark. Primary booking blue swapped to indigo so it harmonises with the
  rest of the indigo accent set.

Side panel + FAB:
- ChatView panel slate-900 / slate-700 border, indigo user bubbles,
  slate-800 assistant bubbles. Repositioned to bottom-24 right-6 so it
  clears the FAB. ChatToggle now indigo, moved to bottom-6 right-6.

Pages:
- Login: slate-950 page, slate-900 card, indigo affordances.
- Department / Resourcing / Bookings / Forecast / ProjectTypeSummary /
  TimeLogDetail / Tutorial all recoloured. Forecast's capacity-decision
  banner is inlined rather than using .card so it can carry the
  colour-coded tint. Bookings virtualised table re-skinned to slate-800
  header / slate-300 rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-17 22:28:41 -04:00
parent dfbc57b22f
commit 6320fb389c
29 changed files with 564 additions and 497 deletions

View file

@ -28,10 +28,10 @@ function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: s
return (
<AuthGate>
<RoleGate slug={slug}>
<div className="flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-slate-950 text-slate-100">
<Navbar />
<StatsBar />
<main className="mx-auto w-full max-w-7xl flex-1 p-4 md:p-6">
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-4 md:px-6 md:py-6">
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
</main>
<ChatToggle />

View file

@ -19,8 +19,8 @@ export default function ChatToggle() {
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`fixed bottom-4 right-4 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-2xl transition-colors ${
open ? 'bg-slate-700 hover:bg-slate-600' : 'bg-blue-600 hover:bg-blue-500'
className={`fixed bottom-6 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full shadow-2xl transition-colors ${
open ? 'bg-slate-700 hover:bg-slate-600' : 'bg-indigo-600 hover:bg-indigo-500'
}`}
aria-label={open ? 'Close AI chat' : 'Open AI chat'}
title={open ? 'Close AI chat' : 'Open AI chat'}

View file

@ -84,15 +84,15 @@ export default function ChatView({ onClose }: Props) {
const hasData = (timelog.rows ?? 0) > 0;
return (
<div className="fixed bottom-20 right-4 z-50 flex h-[70vh] w-[420px] max-w-[92vw] flex-col rounded-xl bg-white shadow-2xl ring-1 ring-slate-300">
<div className="flex items-center gap-2 border-b border-slate-200 px-3 py-2">
<span className={`h-2 w-2 rounded-full ${hasData ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span className="text-sm font-semibold text-slate-800">AI Assistant</span>
<div className="fixed bottom-24 right-6 z-50 flex h-[70vh] w-[420px] max-w-[92vw] flex-col rounded-xl bg-slate-900 shadow-2xl border border-slate-700">
<div className="flex items-center gap-2 border-b border-slate-700 px-3 py-2">
<span className={`h-2 w-2 rounded-full ${hasData ? 'bg-emerald-400' : 'bg-amber-400'}`} />
<span className="text-sm font-semibold text-slate-100">AI Assistant</span>
{messages.length > 0 && (
<button
type="button"
onClick={() => setMessages([])}
className="ml-auto rounded border border-slate-300 px-2 py-0.5 text-[10px] text-slate-500 hover:bg-slate-50"
className="ml-auto rounded border border-slate-600 bg-slate-800 px-2 py-0.5 text-[10px] text-slate-300 hover:bg-slate-700"
>
Clear
</button>
@ -100,7 +100,7 @@ export default function ChatView({ onClose }: Props) {
<button
type="button"
onClick={onClose}
className="ml-2 rounded p-1 text-slate-500 hover:bg-slate-100"
className="ml-2 rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
aria-label="Close AI chat"
>
<X className="h-4 w-4" aria-hidden />
@ -110,7 +110,7 @@ export default function ChatView({ onClose }: Props) {
<div className="flex-1 space-y-3 overflow-y-auto px-3 py-3">
{messages.length === 0 && (
<div className="space-y-2">
<p className="text-xs text-slate-500">Try asking:</p>
<p className="text-xs text-slate-400">Try asking:</p>
<div className="flex flex-col gap-1.5">
{SUGGESTIONS.map((s) => (
<button
@ -118,28 +118,28 @@ export default function ChatView({ onClose }: Props) {
type="button"
onClick={() => void send(s)}
disabled={!hasData || busy}
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 disabled:opacity-40"
className="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-left text-xs text-slate-300 hover:bg-slate-700 disabled:opacity-40"
>
{s}
</button>
))}
</div>
{!hasData && <p className="text-[10px] text-slate-400">Upload a time log first to give the AI real context.</p>}
{!hasData && <p className="text-[10px] text-slate-500">Upload a time log first to give the AI real context.</p>}
</div>
)}
{messages.map((m, i) => (
<div key={i} className={`flex gap-2 ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
{m.role === 'assistant' && (
<div className="mt-0.5 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-blue-600 text-[9px] font-bold text-white">
<div className="mt-0.5 flex h-6 w-6 flex-none items-center justify-center rounded-full bg-indigo-600 text-[9px] font-bold text-white">
AI
</div>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3 py-2 text-xs leading-relaxed ${
m.role === 'user'
? 'rounded-br-sm bg-blue-600 text-white'
: 'rounded-bl-sm bg-slate-100 text-slate-800'
? 'rounded-br-sm bg-indigo-600 text-white'
: 'rounded-bl-sm bg-slate-800 text-slate-200'
}`}
>
{m.content || (busy && i === messages.length - 1 ? '…' : '')}
@ -148,12 +148,12 @@ export default function ChatView({ onClose }: Props) {
))}
{error && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">{error}</div>
<div className="rounded-md border border-red-500/50 bg-red-900/30 px-3 py-2 text-xs text-red-300">{error}</div>
)}
<div ref={bottomRef} />
</div>
<div className="border-t border-slate-200 px-3 py-2">
<div className="border-t border-slate-700 px-3 py-2">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}

View file

@ -26,15 +26,15 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (!this.state.error) return this.props.children;
return (
<div className="card border-red-300 bg-red-50 text-red-800">
<div className="rounded-lg border border-red-500/50 bg-red-900/30 p-4 shadow-md text-red-200">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 flex-none" aria-hidden />
<div className="flex-1">
<div className="font-semibold">
<div className="font-semibold text-red-100">
{this.props.label ? `${this.props.label} failed to render` : 'Something went wrong'}
</div>
<div className="mt-1 text-sm">{this.state.error.message}</div>
<button onClick={this.reset} className="mt-2 text-sm underline">Try again</button>
<div className="mt-1 text-sm text-red-300">{this.state.error.message}</div>
<button onClick={this.reset} className="mt-2 text-sm text-red-200 hover:text-white underline">Try again</button>
</div>
</div>
</div>

View file

@ -9,12 +9,12 @@ export default function ErrorBox({ message, onRetry }: Props) {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
className="flex items-start gap-3 rounded-md border border-red-500/50 bg-red-900/30 p-3 text-sm text-red-200"
>
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden />
<div className="flex-1">
<div className="font-medium">Something went wrong</div>
<div className="mt-0.5 break-words text-red-700">{message}</div>
<div className="font-medium text-red-100">Something went wrong</div>
<div className="mt-0.5 break-words text-red-300">{message}</div>
</div>
{onRetry && (
<button type="button" onClick={onRetry} className="btn-secondary !px-2 !py-1 text-xs">

View file

@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { RotateCcw } from 'lucide-react';
import type { Dispatch } from 'react';
import type { FilterAction, FilterState } from '../lib/filters';
import { PRESET_LABELS, type DatePreset } from '../lib/dates';
@ -19,6 +18,30 @@ interface Props {
showForecastToggle?: boolean;
}
/**
* Small slate dropdown stacked under an `xs` label matches the original
* AppFilters layout. Used by both single and multi selects below.
*/
function Field({
label,
children,
tutorialId,
}: {
label: string;
children: React.ReactNode;
tutorialId?: string;
}) {
return (
<label
className="flex flex-col text-xs text-slate-400 gap-1"
data-tutorial-id={tutorialId}
>
{label}
{children}
</label>
);
}
function MultiSelect({
label,
options,
@ -33,14 +56,12 @@ function MultiSelect({
tutorialId?: string;
}) {
return (
<div className="min-w-[10rem] flex-1">
<label className="label">{label}</label>
<Field label={label} tutorialId={tutorialId}>
<select
multiple
value={selected}
onChange={(e) => onChange(Array.from(e.target.selectedOptions, (o) => o.value))}
className="input h-24"
data-tutorial-id={tutorialId}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-200 text-xs min-w-[130px] h-20"
>
{options.map((o) => (
<option key={o} value={o}>
@ -48,7 +69,7 @@ function MultiSelect({
</option>
))}
</select>
</div>
</Field>
);
}
@ -70,135 +91,134 @@ export default function FilterBar({
);
return (
<div className="card space-y-3" data-tutorial-id="filter-bar">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="label" htmlFor="preset">Date range</label>
<select
id="preset"
value={state.preset}
onChange={(e) => dispatch({ type: 'set-preset', preset: e.target.value as DatePreset })}
className="input"
>
{PRESETS.map((p) => (
<option key={p} value={p}>
{PRESET_LABELS[p]}
</option>
))}
</select>
</div>
<div
className="bg-slate-900/30 border border-slate-800 rounded-md px-4 py-3 flex flex-wrap gap-3 items-end"
data-tutorial-id="filter-bar"
>
<Field label="Date range">
<select
value={state.preset}
onChange={(e) => dispatch({ type: 'set-preset', preset: e.target.value as DatePreset })}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-200 text-xs min-w-[130px]"
>
{PRESETS.map((p) => (
<option key={p} value={p}>
{PRESET_LABELS[p]}
</option>
))}
</select>
</Field>
<div>
<label className="label" htmlFor="from">From</label>
<input
id="from"
type="date"
value={customRange.from}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: e.target.value, to: customRange.to } })
}
className="input"
/>
</div>
<div>
<label className="label" htmlFor="to">To</label>
<input
id="to"
type="date"
value={customRange.to}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: customRange.from, to: e.target.value } })
}
className="input"
/>
</div>
<div className="ml-auto flex items-end gap-3">
{showForecastToggle && (
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={state.showForecast}
onChange={() => dispatch({ type: 'toggle-forecast' })}
data-tutorial-id="forecast-toggle"
/>
{state.showForecast ? 'Forecast line visible' : 'Forecast line hidden'}
</label>
)}
<button type="button" onClick={() => dispatch({ type: 'reset' })} className="btn-secondary">
<RotateCcw className="h-4 w-4" aria-hidden /> Reset
</button>
</div>
</div>
<div className="flex flex-wrap gap-3">
<MultiSelect
label="Department"
options={departments}
selected={state.departments}
onChange={(v) => dispatch({ type: 'set-departments', departments: v })}
tutorialId="filter-department"
<Field label="From">
<input
type="date"
value={customRange.from}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: e.target.value, to: customRange.to } })
}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-200 text-xs"
/>
<MultiSelect
label="Name"
options={names}
selected={state.names}
onChange={(v) => dispatch({ type: 'set-names', names: v })}
tutorialId="filter-name"
</Field>
<Field label="To">
<input
type="date"
value={customRange.to}
onChange={(e) =>
dispatch({ type: 'set-custom-range', range: { from: customRange.from, to: e.target.value } })
}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-200 text-xs"
/>
<div className="min-w-[10rem] flex-1">
<label className="label" htmlFor="billing-type">Billing type</label>
<select
id="billing-type"
value={state.billingType ?? ''}
onChange={(e) =>
dispatch({ type: 'set-billing-type', billingType: e.target.value === '' ? null : e.target.value })
}
className="input"
>
<option value="">All</option>
{billingTypes.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</div>
</div>
</Field>
{(brands.length > 0 || divisions.length > 0 || hubs.length > 0 || userRoles.length > 0) && (
<div className="flex flex-wrap gap-3 border-t border-slate-200 pt-3">
<MultiSelect
label="Brand"
options={brands}
selected={state.brands}
onChange={(v) => dispatch({ type: 'set-brands', brands: v })}
tutorialId="filter-brand"
/>
<MultiSelect
label="Division"
options={divisions}
selected={state.divisions}
onChange={(v) => dispatch({ type: 'set-divisions', divisions: v })}
tutorialId="filter-division"
/>
<MultiSelect
label="Hub / Market"
options={hubs}
selected={state.hubs}
onChange={(v) => dispatch({ type: 'set-hubs', hubs: v })}
tutorialId="filter-hub"
/>
<MultiSelect
label="User role"
options={userRoles}
selected={state.userRoles}
onChange={(v) => dispatch({ type: 'set-user-roles', userRoles: v })}
tutorialId="filter-user-role"
/>
</div>
<MultiSelect
label="Department"
options={departments}
selected={state.departments}
onChange={(v) => dispatch({ type: 'set-departments', departments: v })}
tutorialId="filter-department"
/>
<MultiSelect
label="Name"
options={names}
selected={state.names}
onChange={(v) => dispatch({ type: 'set-names', names: v })}
tutorialId="filter-name"
/>
<Field label="Billing type">
<select
value={state.billingType ?? ''}
onChange={(e) =>
dispatch({ type: 'set-billing-type', billingType: e.target.value === '' ? null : e.target.value })
}
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-slate-200 text-xs min-w-[130px]"
>
<option value="">All</option>
{billingTypes.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</Field>
{brands.length > 0 && (
<MultiSelect
label="Brand"
options={brands}
selected={state.brands}
onChange={(v) => dispatch({ type: 'set-brands', brands: v })}
tutorialId="filter-brand"
/>
)}
{divisions.length > 0 && (
<MultiSelect
label="Division"
options={divisions}
selected={state.divisions}
onChange={(v) => dispatch({ type: 'set-divisions', divisions: v })}
tutorialId="filter-division"
/>
)}
{hubs.length > 0 && (
<MultiSelect
label="Hub / Market"
options={hubs}
selected={state.hubs}
onChange={(v) => dispatch({ type: 'set-hubs', hubs: v })}
tutorialId="filter-hub"
/>
)}
{userRoles.length > 0 && (
<MultiSelect
label="User role"
options={userRoles}
selected={state.userRoles}
onChange={(v) => dispatch({ type: 'set-user-roles', userRoles: v })}
tutorialId="filter-user-role"
/>
)}
{showForecastToggle && (
<label className="flex items-center gap-2 text-xs text-slate-300 mt-4">
<input
type="checkbox"
checked={state.showForecast}
onChange={() => dispatch({ type: 'toggle-forecast' })}
data-tutorial-id="forecast-toggle"
className="accent-indigo-500"
/>
{state.showForecast ? 'Forecast visible' : 'Forecast hidden'}
</label>
)}
<button
type="button"
onClick={() => dispatch({ type: 'reset' })}
className="text-xs text-slate-400 hover:text-slate-200 mt-4 ml-auto"
>
Reset
</button>
</div>
);
}

View file

@ -1,128 +1,114 @@
import { useRef } from 'react';
import { Upload, FileSpreadsheet, X, Loader2 } from 'lucide-react';
import { useDataContext } from '../hooks/useDataContext';
const ACCEPT = '.xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv';
interface SlotProps {
label: string;
filename: string | null;
rows: number;
uploading: boolean;
error: string | null;
onFile: (f: File) => void;
onClear?: () => void;
}
function Slot({ label, filename, rows, uploading, error, onFile, onClear }: SlotProps) {
const inputRef = useRef<HTMLInputElement>(null);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) onFile(f);
e.target.value = '';
};
const status = uploading
? 'uploading'
: error
? 'error'
: filename
? 'loaded'
: 'empty';
return (
<div
className={[
'flex items-center gap-2 rounded-md border px-2 py-1 text-xs transition-colors',
status === 'loaded' && 'border-emerald-300 bg-emerald-50 text-emerald-900',
status === 'empty' && 'border-slate-300 bg-white text-slate-600 hover:border-slate-400',
status === 'uploading' && 'border-slate-300 bg-slate-50 text-slate-500',
status === 'error' && 'border-red-300 bg-red-50 text-red-800',
].filter(Boolean).join(' ')}
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" aria-hidden />
)}
<div className="flex min-w-0 flex-col">
<span className="font-semibold uppercase tracking-wide opacity-70">{label}</span>
{status === 'loaded' && (
<span className="max-w-[180px] truncate" title={filename ?? ''}>
{filename} <span className="opacity-60">· {rows.toLocaleString()} rows</span>
</span>
)}
{status === 'uploading' && <span>Parsing</span>}
{status === 'empty' && <span>Not uploaded</span>}
{status === 'error' && <span className="truncate" title={error ?? ''}>{error}</span>}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
className="hidden"
onChange={onChange}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="ml-auto inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] text-slate-700 hover:bg-slate-50 disabled:opacity-50"
aria-label={`Upload ${label}`}
>
<Upload className="h-3 w-3" aria-hidden /> {filename ? 'Replace' : 'Upload'}
</button>
{filename && onClear && (
<button
type="button"
onClick={onClear}
className="rounded p-0.5 text-slate-500 hover:bg-white hover:text-slate-800"
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" aria-hidden />
</button>
)}
</div>
);
}
/**
* Three pill-style file uploads that sit in the header's right side: Time
* Log (indigo when loaded), Deliverable (emerald when loaded), Project
* Summary (violet when loaded). Re-uploading replaces the file there is no
* explicit Clear button (matches the original SPA's behaviour).
*/
export default function HeaderUploads() {
const {
timelog, deliverable, projectSummary,
uploadTimelog, uploadDeliverable, uploadProjectSummary,
clearTimelog,
} = useDataContext();
const onPick = (handler: (f: File) => Promise<void>) => (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) void handler(f);
e.target.value = '';
};
return (
<div className="flex flex-wrap items-center gap-2" data-tutorial-id="header-uploads">
<Slot
label="Time Log"
filename={timelog.filename}
rows={timelog.rows}
uploading={timelog.uploading}
error={timelog.error}
onFile={(f) => void uploadTimelog(f)}
onClear={clearTimelog}
/>
<Slot
label="Deliverable"
filename={deliverable.filename}
rows={deliverable.rows}
uploading={deliverable.uploading}
error={deliverable.error}
onFile={(f) => void uploadDeliverable(f)}
/>
<Slot
label="Project Summary"
filename={projectSummary.filename}
rows={projectSummary.rows}
uploading={projectSummary.uploading}
error={projectSummary.error}
onFile={(f) => void uploadProjectSummary(f)}
/>
<div className="flex flex-wrap items-center gap-3" data-tutorial-id="header-uploads">
{/* Time Log — indigo primary */}
<label className="cursor-pointer">
<input
type="file"
accept={ACCEPT}
onChange={onPick(uploadTimelog)}
className="hidden"
disabled={timelog.uploading}
/>
<span className="inline-flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{timelog.uploading ? 'Parsing…' : timelog.rows ? '↑ Time Log' : 'Upload Time Log'}
</span>
</label>
{timelog.filename && !timelog.error && (
<span className="text-[10px] text-slate-500 max-w-[160px] truncate" title={timelog.filename}>
{timelog.filename}
</span>
)}
{/* Deliverable — emerald when loaded, neutral when empty */}
<label className="cursor-pointer">
<input
type="file"
accept={ACCEPT}
onChange={onPick(uploadDeliverable)}
className="hidden"
disabled={deliverable.uploading}
/>
<span
className={`inline-flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg border transition-colors ${
deliverable.rows
? 'bg-emerald-700/30 border-emerald-500/50 text-emerald-300 hover:bg-emerald-700/50'
: 'bg-slate-700 border-slate-600 text-slate-300 hover:bg-slate-600'
}`}
>
{deliverable.uploading
? 'Parsing…'
: deliverable.rows
? `${deliverable.rows.toLocaleString()} deliverables`
: '↑ Deliverable'}
</span>
</label>
{deliverable.filename && !deliverable.error && (
<span className="text-[10px] text-slate-500 max-w-[160px] truncate" title={deliverable.filename}>
{deliverable.filename}
</span>
)}
{/* Project Summary — violet when loaded, neutral when empty */}
<label className="cursor-pointer">
<input
type="file"
accept={ACCEPT}
onChange={onPick(uploadProjectSummary)}
className="hidden"
disabled={projectSummary.uploading}
/>
<span
className={`inline-flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg border transition-colors ${
projectSummary.rows
? 'bg-violet-700/30 border-violet-500/50 text-violet-300 hover:bg-violet-700/50'
: 'bg-slate-700 border-slate-600 text-slate-300 hover:bg-slate-600'
}`}
>
{projectSummary.uploading
? 'Parsing…'
: projectSummary.rows
? `${projectSummary.rows.toLocaleString()} projects`
: '↑ Project Summary'}
</span>
</label>
{projectSummary.filename && !projectSummary.error && (
<span className="text-[10px] text-violet-500 max-w-[160px] truncate" title={projectSummary.filename}>
{projectSummary.filename}
</span>
)}
{/* Errors collapse into a small, truncated marker so the header height
doesn't jump. Full message still appears on hover. */}
{(timelog.error || deliverable.error || projectSummary.error) && (
<span
className="text-xs text-red-400 truncate max-w-[180px]"
title={timelog.error ?? deliverable.error ?? projectSummary.error ?? ''}
>
{(timelog.error ?? deliverable.error ?? projectSummary.error)?.slice(0, 60)}
</span>
)}
</div>
);
}

View file

@ -53,39 +53,39 @@ export default function HourBreakdown({ period, employee, from, to, timelogHash,
return (
<div className="card" data-tutorial-id="hour-breakdown">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-700">
<h3 className="text-sm font-semibold text-slate-200">
Hour Breakdown {formatPeriod(period)}
{employee && <span className="ml-2 text-slate-500">· {employee}</span>}
{employee && <span className="ml-2 text-slate-400">· {employee}</span>}
</h3>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-slate-500 hover:bg-slate-100"
className="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
aria-label="Close hour breakdown"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
{loading && <div className="text-sm text-slate-500">Loading breakdown</div>}
{error && <div className="text-sm text-red-700">{error}</div>}
{loading && <div className="text-sm text-slate-400">Loading breakdown</div>}
{error && <div className="text-sm text-red-300">{error}</div>}
{!loading && !error && data && (
<div className="grid gap-4 md:grid-cols-2">
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-400">
Logged by project
</div>
{!data.hasLogged ? (
<div className="rounded-md bg-slate-50 p-3 text-sm text-slate-600">
<div className="rounded-md bg-slate-800 p-3 text-sm text-slate-300 border border-slate-700">
Upload a time log to see logged breakdown.
</div>
) : data.logged.length === 0 ? (
<div className="text-sm text-slate-500">No logged hours in this period.</div>
<div className="text-sm text-slate-400">No logged hours in this period.</div>
) : (
<table className="w-full text-sm">
<table className="w-full text-sm text-slate-300">
<thead>
<tr className="text-left text-xs uppercase text-slate-500">
<tr className="text-left text-xs uppercase text-slate-400">
<th className="pb-1">Project</th>
<th className="pb-1 text-right">Hours</th>
<th className="pb-1 text-right">Billable</th>
@ -93,10 +93,10 @@ export default function HourBreakdown({ period, employee, from, to, timelogHash,
</thead>
<tbody>
{data.logged.map((r) => (
<tr key={r.project} className="border-t border-slate-100">
<tr key={r.project} className="border-t border-slate-800">
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
<td className="py-1 text-right tabular-nums text-emerald-700">{formatHours(r.billable)}</td>
<td className="py-1 text-right tabular-nums text-emerald-400">{formatHours(r.billable)}</td>
</tr>
))}
</tbody>
@ -105,15 +105,15 @@ export default function HourBreakdown({ period, employee, from, to, timelogHash,
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-400">
Booked by project
</div>
{data.booked.length === 0 ? (
<div className="text-sm text-slate-500">No bookings in this period.</div>
<div className="text-sm text-slate-400">No bookings in this period.</div>
) : (
<table className="w-full text-sm">
<table className="w-full text-sm text-slate-300">
<thead>
<tr className="text-left text-xs uppercase text-slate-500">
<tr className="text-left text-xs uppercase text-slate-400">
<th className="pb-1">Project</th>
<th className="pb-1 text-right">Hours</th>
<th className="pb-1 text-right">Active</th>
@ -122,11 +122,11 @@ export default function HourBreakdown({ period, employee, from, to, timelogHash,
</thead>
<tbody>
{data.booked.map((r) => (
<tr key={r.project} className="border-t border-slate-100">
<tr key={r.project} className="border-t border-slate-800">
<td className="py-1 pr-2 truncate" title={r.project}>{r.project}</td>
<td className="py-1 text-right tabular-nums">{formatHours(r.hours)}</td>
<td className="py-1 text-right tabular-nums text-blue-700">{formatHours(r.active)}</td>
<td className="py-1 text-right tabular-nums text-indigo-500">{formatHours(r.soft)}</td>
<td className="py-1 text-right tabular-nums text-indigo-300">{formatHours(r.active)}</td>
<td className="py-1 text-right tabular-nums text-indigo-400">{formatHours(r.soft)}</td>
</tr>
))}
</tbody>

View file

@ -48,17 +48,17 @@ export default function KpiTiles({ totals, period = 'week' }: Props) {
return (
<div className="card space-y-2" data-tutorial-id="kpi-tiles">
<div className="flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Headline numbers</h3>
<h3 className="text-sm font-semibold text-slate-200">Headline numbers</h3>
{periodCovered && (
<span className="text-xs text-slate-500">Period covered: {periodCovered}</span>
<span className="text-xs text-slate-400">Period covered: {periodCovered}</span>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{tiles.map((t) => (
<div key={t.label} className="rounded-md bg-slate-50 p-3 ring-1 ring-slate-200">
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-500">{t.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-slate-900">{t.value}</div>
{t.hint && <div className="mt-0.5 text-xs text-slate-500">{t.hint}</div>}
<div key={t.label} className="rounded-md bg-slate-800 p-3 border border-slate-700">
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-400">{t.label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-white">{t.value}</div>
{t.hint && <div className="mt-0.5 text-xs text-slate-400">{t.hint}</div>}
</div>
))}
</div>

View file

@ -2,8 +2,8 @@ import { Loader2 } from 'lucide-react';
export default function Loading({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-3 p-8 text-slate-500">
<Loader2 className="h-5 w-5 animate-spin" aria-hidden />
<div className="flex items-center justify-center gap-3 p-8 text-slate-400">
<Loader2 className="h-5 w-5 animate-spin text-indigo-500" aria-hidden />
<span>{label}</span>
</div>
);

View file

@ -1,6 +1,7 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LogOut, BarChart3 } from 'lucide-react';
import { BarChart3 } from 'lucide-react';
import { canAccess, useAuth } from '../hooks/useAuth';
import { useDataContext } from '../hooks/useDataContext';
import HeaderUploads from './HeaderUploads';
interface Tab {
@ -25,6 +26,7 @@ const TABS: Tab[] = [
export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { timelog } = useDataContext();
const handleLogout = async () => {
await logout();
@ -33,54 +35,74 @@ export default function Navbar() {
const visible = TABS.filter((t) => canAccess(user?.role, t.slug));
// Mirror the original SPA: filename + upload time once a timelog is loaded,
// tagline otherwise. We don't have a true "uploaded at" timestamp on the
// context, so we fall back to "ready" when the upload is parsed.
const subtitle = timelog.filename
? `${timelog.filename} · ${timelog.rows.toLocaleString()} rows`
: "Daily capacity & project intake analysis";
return (
<header className="bg-slate-900 text-slate-100 shadow-md" data-tutorial-id="navbar">
<div className="mx-auto flex w-full max-w-7xl items-center gap-4 px-4 py-3 md:px-6">
<div className="flex items-center gap-2 font-semibold tracking-wide">
<BarChart3 className="h-5 w-5 text-blue-400" aria-hidden />
<span>Utilisation</span>
<header className="bg-slate-900 border-b border-slate-700" data-tutorial-id="navbar">
{/* Title row: brand on the left, uploads + identity on the right */}
<div className="flex items-center gap-4 px-4 py-3 md:px-6">
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-indigo-400" aria-hidden />
<div className="leading-tight">
<h1 className="text-xl font-bold text-white">L&apos;Oréal Utilisation Dashboard</h1>
<p className="text-xs text-slate-400 truncate max-w-[60ch]" title={subtitle}>
{subtitle}
</p>
</div>
</div>
<nav className="flex flex-wrap items-center gap-1">
{visible.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
data-tutorial-id={`tab-${tab.slug}`}
className={({ isActive }) =>
[
'rounded-md px-3 py-1.5 text-sm font-medium transition',
isActive ? 'bg-slate-800 text-white' : 'text-slate-300 hover:bg-slate-800 hover:text-white',
].join(' ')
}
>
{tab.label}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
{user && (
<span className="text-slate-400">
Signed in as <span className="text-slate-200">{user.username}</span>
{user.role && <span className="ml-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-slate-300">{user.role}</span>}
</span>
)}
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-md bg-slate-800 px-3 py-1.5 text-sm text-slate-200 hover:bg-slate-700"
data-tutorial-id="logout-button"
>
<LogOut className="h-4 w-4" aria-hidden /> Log out
</button>
</div>
</div>
{/* Compact upload tray — always visible so every data page can feed it. */}
<div className="border-t border-slate-800 bg-slate-50 px-4 py-2 md:px-6">
<div className="mx-auto w-full max-w-7xl">
<div className="ml-auto flex items-center gap-3">
<HeaderUploads />
{user && (
<div className="flex items-center gap-2 border-l border-slate-700 pl-3 ml-1">
<span className="text-xs text-slate-400 hidden sm:inline">
{user.username}
{user.role && (
<span className="ml-1 rounded bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-slate-300">
{user.role}
</span>
)}
</span>
<button
type="button"
onClick={handleLogout}
className="text-xs text-slate-500 hover:text-red-400 transition-colors"
data-tutorial-id="logout-button"
>
Sign out
</button>
</div>
)}
</div>
</div>
{/* Tab row: bordered "tab" look, hangs off the content border. */}
<nav className="flex flex-wrap gap-1 px-4 pt-2 md:px-6 border-b border-slate-800">
{visible.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
data-tutorial-id={`tab-${tab.slug}`}
className={({ isActive }) =>
[
'px-4 py-2 text-sm font-medium rounded-t-lg transition-colors',
isActive
? 'bg-slate-800 text-white border-t border-x border-slate-700'
: 'text-slate-400 hover:text-slate-200',
].join(' ')
}
>
{tab.label}
</NavLink>
))}
</nav>
</header>
);
}

View file

@ -22,7 +22,7 @@ const OPTIONS: { value: PeriodKind | 'day'; label: string }[] = [
export default function PeriodToggle({ value, onChange, includeDay = false }: Props) {
return (
<div
className="inline-flex items-center rounded-md border border-slate-300 bg-white p-0.5 text-sm shadow-sm"
className="inline-flex items-center rounded-md border border-slate-600 bg-slate-800 p-0.5 text-sm shadow-sm"
data-tutorial-id="period-toggle"
role="radiogroup"
aria-label="Aggregation period"
@ -45,7 +45,7 @@ export default function PeriodToggle({ value, onChange, includeDay = false }: Pr
}}
className={[
'rounded px-3 py-1 text-xs font-medium transition',
active ? 'bg-blue-600 text-white shadow-sm' : 'text-slate-600 hover:bg-slate-100',
active ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-300 hover:bg-slate-700',
disabled ? 'cursor-not-allowed opacity-40 hover:bg-transparent' : '',
].join(' ')}
>

View file

@ -25,13 +25,13 @@ export default function StatsBar() {
return (
<div
className="bg-slate-900/95 text-slate-100 border-b border-slate-800 px-4 md:px-6 py-2 flex flex-wrap gap-6 text-sm"
className="bg-slate-900/50 border-b border-slate-800 px-4 md:px-6 py-3 flex flex-wrap gap-8 text-sm"
data-tutorial-id="stats-bar"
>
{items.map((it) => (
<div key={it.label} className="flex flex-col">
<span className="text-[10px] uppercase tracking-wide text-slate-400">{it.label}</span>
<span className="font-semibold tabular-nums">{it.value}</span>
<div key={it.label}>
<div className="text-xs text-slate-400">{it.label}</div>
<div className="font-semibold text-white tabular-nums">{it.value}</div>
</div>
))}
</div>

View file

@ -49,11 +49,11 @@ export default function UploadButton({
onDrop={onDrop}
className={[
'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition',
dragOver ? 'border-blue-400 bg-blue-50' : 'border-slate-300 bg-white',
dragOver ? 'border-indigo-500 bg-indigo-900/20' : 'border-slate-600 bg-slate-900',
].join(' ')}
>
<FileSpreadsheet className="h-8 w-8 text-slate-400" aria-hidden />
<p className="text-sm text-slate-600">
<p className="text-sm text-slate-300">
Drag &amp; drop a timelog file (<code>.xlsx</code> or <code>.csv</code>), or
</p>
<button
@ -67,11 +67,11 @@ export default function UploadButton({
</button>
<input ref={inputRef} type="file" accept={ACCEPT} hidden onChange={onChange} />
{filename && !error && (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500">
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<span>
{filename} {rowCount} row{rowCount === 1 ? '' : 's'}
</span>
<button type="button" onClick={onClear} className="text-slate-400 hover:text-slate-700">
<button type="button" onClick={onClear} className="text-slate-500 hover:text-slate-200">
<X className="h-3.5 w-3.5" aria-hidden />
<span className="sr-only">Clear</span>
</button>
@ -79,13 +79,13 @@ export default function UploadButton({
)}
</div>
{error && <div className="rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-800">{error}</div>}
{error && <div className="rounded-md border border-red-500/50 bg-red-900/30 p-2 text-xs text-red-300">{error}</div>}
{unrecognised.length > 0 && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-2 text-xs text-yellow-900">
<div className="rounded-md border border-amber-500/50 bg-amber-900/30 p-2 text-xs text-amber-200">
<strong>Couldn&apos;t find expected column{unrecognised.length > 1 ? 's' : ''}:</strong>{' '}
{unrecognised.join(', ')}
<span className="ml-1 text-yellow-700">
<span className="ml-1 text-amber-300">
Zoho may have renamed a header. Charts will be incomplete.
</span>
</div>

View file

@ -88,9 +88,13 @@ const SERIES: { key: keyof Bucket; name: string; fill: string }[] = [
{ key: 'billable', name: 'Billable', fill: '#10b981' },
{ key: 'nonBillable', name: 'Non-billable', fill: '#f59e0b' },
{ key: 'leave', name: 'Leave', fill: '#a855f7' },
{ key: 'idle', name: 'Idle', fill: '#cbd5e1' },
{ key: 'idle', name: 'Idle', fill: '#475569' },
];
const AXIS_TICK = { fontSize: 11, fill: '#94a3b8' } as const;
const Y_TICK = { fontSize: 12, fill: '#94a3b8' } as const;
const TOOLTIP_CONTENT = { background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' } as const;
export default function BillabilityBreakdown({ rows }: Props) {
const data = aggregate(rows);
const otherCount = data.length > 0 && data[data.length - 1].employee.startsWith('Other (')
@ -100,8 +104,8 @@ export default function BillabilityBreakdown({ rows }: Props) {
return (
<div className="card" data-tutorial-id="chart-billability-breakdown">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Billability Breakdown</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<h3 className="text-sm font-semibold text-slate-200">Billability Breakdown</h3>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
{otherCount > 0 && <span>Top {TOP_N} by available + billable</span>}
{SERIES.map((s) => (
<span key={s.key as string} className="inline-flex items-center gap-1">
@ -118,13 +122,15 @@ export default function BillabilityBreakdown({ rows }: Props) {
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="employee" tick={AXIS_TICK} angle={-25} textAnchor="end" height={60} stroke="#475569" />
<YAxis tick={Y_TICK} stroke="#475569" />
<Tooltip
wrapperStyle={{ maxWidth: 360 }}
itemStyle={{ fontSize: 12 }}
labelStyle={{ fontSize: 12, fontWeight: 600 }}
contentStyle={TOOLTIP_CONTENT}
itemStyle={{ fontSize: 12, color: '#e2e8f0' }}
labelStyle={{ fontSize: 12, fontWeight: 600, color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
filterNull
/>
{SERIES.map((s) => (

View file

@ -66,17 +66,22 @@ export default function BookingVsActual({ rows }: Props) {
return (
<div className="card" data-tutorial-id="chart-booking-vs-actual">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Booking vs Actual</h3>
<h3 className="mb-2 text-sm font-semibold text-slate-200">Booking vs Actual</h3>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="activeBooked" name="Active Booked" stackId="booked" fill="#2563eb" />
<Bar dataKey="softBooked" name="Soft Booked" stackId="booked" fill="#93c5fd" />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="employee" tick={{ fontSize: 11, fill: '#94a3b8' }} angle={-25} textAnchor="end" height={60} stroke="#475569" />
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar dataKey="activeBooked" name="Active Booked" stackId="booked" fill="#6366f1" />
<Bar dataKey="softBooked" name="Soft Booked" stackId="booked" fill="#a5b4fc" />
<Bar dataKey="actual" name="Actual" fill="#10b981" />
</BarChart>
</ResponsiveContainer>

View file

@ -76,15 +76,21 @@ function split(rows: UtilisationSummaryRow[]): { fte: Bucket[]; freelancer: Buck
function MiniChart({ title, data, fill }: { title: string; data: Bucket[]; fill: string }) {
return (
<div className="card flex-1">
<h3 className="mb-2 text-sm font-semibold text-slate-700">{title}</h3>
<h3 className="mb-2 text-sm font-semibold text-slate-200">{title}</h3>
<div className="h-72 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} unit="%" />
<Tooltip formatter={(v: number) => `${v}%`} />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="employee" tick={{ fontSize: 11, fill: '#94a3b8' }} angle={-25} textAnchor="end" height={60} stroke="#475569" />
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" unit="%" />
<Tooltip
formatter={(v: number) => `${v}%`}
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar dataKey="utilisation" name="Utilisation %" fill={fill} />
</BarChart>
</ResponsiveContainer>
@ -97,7 +103,7 @@ export default function FTEvsFreelancer({ rows }: Props) {
const { fte, freelancer } = split(rows);
return (
<div className="flex flex-col gap-4 md:flex-row" data-tutorial-id="chart-fte-vs-freelancer">
<MiniChart title="FTE" data={fte} fill="#2563eb" />
<MiniChart title="FTE" data={fte} fill="#6366f1" />
<MiniChart title="Freelancers" data={freelancer} fill="#a855f7" />
</div>
);

View file

@ -68,8 +68,8 @@ export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }
return (
<div className="card" data-tutorial-id="chart-monthly-utilisation">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Monthly Utilisation</h3>
<span className="text-xs text-slate-500">
<h3 className="text-sm font-semibold text-slate-200">Monthly Utilisation</h3>
<span className="text-xs text-slate-400">
{onPeriodClick ? 'Click a bar for Hour Breakdown · ' : ''}
{showForecast ? 'Forecast line visible' : 'Forecast line hidden'}
</span>
@ -77,16 +77,21 @@ export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }
<div className="h-80 w-full">
<ResponsiveContainer>
<ComposedChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar
dataKey="activeBooked"
name="Active Booked"
stackId="booked"
fill="#2563eb"
fill="#6366f1"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
@ -94,12 +99,12 @@ export default function MonthlyUtilisation({ rows, showForecast, onPeriodClick }
dataKey="softBooked"
name="Soft Booked"
stackId="booked"
fill="#93c5fd"
fill="#a5b4fc"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
<Bar dataKey="available" name="Available" fill="#475569" />
{showForecast && (
<Line
type="monotone"

View file

@ -19,10 +19,10 @@ interface Props {
// "Other"; drop the legend entirely and rely on the tooltip.
const TOP_N = 10;
const PALETTE = [
'#2563eb', '#10b981', '#f59e0b', '#a855f7', '#ef4444',
'#6366f1', '#10b981', '#f59e0b', '#a855f7', '#ef4444',
'#0ea5e9', '#84cc16', '#f97316', '#ec4899', '#14b8a6',
];
const OTHER_FILL = '#94a3b8';
const OTHER_FILL = '#475569';
interface Row {
employee: string;
@ -68,19 +68,21 @@ export default function ProjectLoadPerPerson({ bookings }: Props) {
return (
<div className="card" data-tutorial-id="chart-project-load">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Project Load per Person</h3>
<span className="text-xs text-slate-500">Top {TOP_N} projects by hours · hover bars for details</span>
<h3 className="text-sm font-semibold text-slate-200">Project Load per Person</h3>
<span className="text-xs text-slate-400">Top {TOP_N} projects by hours · hover bars for details</span>
</div>
<div className="h-96 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="employee" tick={{ fontSize: 11 }} angle={-25} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 12 }} />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="employee" tick={{ fontSize: 11, fill: '#94a3b8' }} angle={-25} textAnchor="end" height={60} stroke="#475569" />
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
wrapperStyle={{ maxWidth: 360 }}
itemStyle={{ fontSize: 12 }}
labelStyle={{ fontSize: 12, fontWeight: 600 }}
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ fontSize: 12, color: '#e2e8f0' }}
labelStyle={{ fontSize: 12, fontWeight: 600, color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
// Hide zero-value series in the tooltip to keep it readable.
filterNull
/>

View file

@ -62,24 +62,29 @@ export default function WeeklyUtilisation({ rows, onPeriodClick, period = 'week'
return (
<div className="card" data-tutorial-id="chart-weekly-utilisation">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
<h3 className="text-sm font-semibold text-slate-200">{title}</h3>
{onPeriodClick && (
<span className="text-xs text-slate-500">Click a bar for Hour Breakdown</span>
<span className="text-xs text-slate-400">Click a bar for Hour Breakdown</span>
)}
</div>
<div className="h-80 w-full">
<ResponsiveContainer>
<BarChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<YAxis tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar
dataKey="activeBooked"
name="Active Booked"
stackId="booked"
fill="#2563eb"
fill="#6366f1"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
@ -87,12 +92,12 @@ export default function WeeklyUtilisation({ rows, onPeriodClick, period = 'week'
dataKey="softBooked"
name="Soft Booked"
stackId="booked"
fill="#93c5fd"
fill="#a5b4fc"
onClick={onBarClick}
style={{ cursor: onPeriodClick ? 'pointer' : 'default' }}
/>
<Bar dataKey="logged" name="Logged" fill="#0ea5e9" />
<Bar dataKey="available" name="Available" fill="#cbd5e1" />
<Bar dataKey="available" name="Available" fill="#475569" />
</BarChart>
</ResponsiveContainer>
</div>

View file

@ -4,7 +4,7 @@
@layer base {
:root {
color-scheme: light;
color-scheme: dark;
}
html,
@ -14,8 +14,8 @@
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
@apply bg-slate-950 text-slate-100 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
}
@ -24,18 +24,18 @@
@apply inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium shadow-sm transition;
}
.btn-primary {
@apply btn bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50;
@apply btn bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50;
}
.btn-secondary {
@apply btn bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50;
@apply btn bg-slate-700 border border-slate-600 text-slate-200 hover:bg-slate-600 disabled:opacity-50;
}
.card {
@apply rounded-lg bg-white p-4 shadow-sm ring-1 ring-slate-200;
@apply rounded-lg bg-slate-900 p-4 border border-slate-800 shadow-md;
}
.input {
@apply w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500;
@apply w-full rounded-md border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500;
}
.label {
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500;
@apply mb-1 block text-xs font-medium uppercase tracking-wide text-slate-400;
}
}

View file

@ -99,7 +99,7 @@ export default function Bookings() {
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-slate-500">
<div className="text-xs text-slate-400">
{total.toLocaleString()} booking{total === 1 ? '' : 's'}
{cachedAt && <span className="ml-2"> cached at {new Date(cachedAt).toLocaleString('en-GB')}</span>}
</div>
@ -129,7 +129,7 @@ export default function Bookings() {
{!loading && !error && (
<div className="card p-0" data-tutorial-id="bookings-table">
<div className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] gap-0 border-b border-slate-200 bg-slate-50 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] gap-0 border-b border-slate-700 bg-slate-800 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-300">
<div>Resource</div>
<div>Project #</div>
<div>Project</div>
@ -150,22 +150,22 @@ export default function Bookings() {
<div
key={b.id}
style={{ height: ROW_HEIGHT }}
className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] items-center gap-0 border-b border-slate-100 px-3 text-sm text-slate-700"
className="grid grid-cols-[1.6fr_1fr_1.2fr_2fr_1fr_0.8fr_1fr] items-center gap-0 border-b border-slate-800 px-3 text-sm text-slate-300 hover:bg-slate-800/50"
>
<div className="truncate" title={b.resourceName}>
{b.resourceName}
{b.placeholder && <span className="ml-1 rounded bg-amber-100 px-1 text-[10px] text-amber-800">PLACEHOLDER</span>}
{b.placeholder && <span className="ml-1 rounded bg-amber-900/40 border border-amber-500/50 px-1 text-[10px] text-amber-300">PLACEHOLDER</span>}
</div>
<div className="truncate text-slate-500">{b.projectNumber}</div>
<div className="truncate text-slate-400">{b.projectNumber}</div>
<div className="truncate" title={b.projectName}>{b.projectName}</div>
<div className="truncate" title={b.task}>{b.task}</div>
<div className="truncate text-slate-500">{b.startDate} {b.endDate}</div>
<div className="truncate text-slate-400">{b.startDate} {b.endDate}</div>
<div className="text-right tabular-nums">{(Number(b.totalHoursBooked) || 0).toFixed(1)}</div>
<div className="truncate text-slate-500">{b.bookingStatus}</div>
<div className="truncate text-slate-400">{b.bookingStatus}</div>
</div>
))}
{total === 0 && (
<div className="p-6 text-center text-sm text-slate-500">No bookings match the current filters.</div>
<div className="p-6 text-center text-sm text-slate-400">No bookings match the current filters.</div>
)}
</div>
</div>

View file

@ -86,8 +86,8 @@ export default function Department() {
return (
<div className="space-y-4">
<section className="card">
<h2 className="text-base font-semibold text-slate-800">How to Use the Department Tab</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-600">
<h2 className="text-base font-semibold text-slate-100">How to Use the Department Tab</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-400">
<li>Upload your timelog export (.xlsx or .csv). It stays in this session only.</li>
<li>Pick a date preset (or custom range) and narrow by department or name.</li>
<li>The charts below recompute automatically. Toggle the forecast line to compare scenarios.</li>
@ -108,7 +108,7 @@ export default function Department() {
userRoles={dimensions.userRoles}
/>
{timelog.unrecognised.length > 0 && (
<div className="rounded-md border border-yellow-300 bg-yellow-50 p-2 text-xs text-yellow-900">
<div className="rounded-md border border-amber-500/50 bg-amber-900/30 p-2 text-xs text-amber-200">
<strong>Time log is missing expected column{timelog.unrecognised.length > 1 ? 's' : ''}:</strong>{' '}
{timelog.unrecognised.join(', ')} charts may be incomplete.
</div>

View file

@ -29,10 +29,10 @@ function decisionIcon(decision: string) {
function decisionStyle(decision: string): string {
const d = decision.toLowerCase();
if (d.includes('overload')) return 'bg-red-50 text-red-800 ring-red-200';
if (d.includes('at capacity')) return 'bg-amber-50 text-amber-800 ring-amber-200';
if (d.includes('ok')) return 'bg-emerald-50 text-emerald-800 ring-emerald-200';
return 'bg-slate-50 text-slate-700 ring-slate-200';
if (d.includes('overload')) return 'bg-red-900/30 text-red-200 border-red-500/50';
if (d.includes('at capacity')) return 'bg-amber-900/30 text-amber-200 border-amber-500/50';
if (d.includes('ok')) return 'bg-emerald-900/30 text-emerald-200 border-emerald-500/50';
return 'bg-slate-800 text-slate-200 border-slate-700';
}
export default function ForecastPage() {
@ -82,8 +82,8 @@ export default function ForecastPage() {
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Forecast Next 4 weeks</h1>
<p className="mt-1 text-xs text-slate-500">
<h1 className="text-base font-semibold text-slate-100">Forecast Next 4 weeks</h1>
<p className="mt-1 text-xs text-slate-400">
Active asset count and exit rate per week, plus the department capacity baseline derived
from your historical hours-per-asset and headcount. Upload a Project Summary file to
improve accuracy.
@ -91,7 +91,7 @@ export default function ForecastPage() {
</section>
{!timelog.hash && (
<div className="card text-sm text-slate-600">
<div className="card text-sm text-slate-300">
Upload a time log on the Department tab to populate the forecast.
</div>
)}
@ -102,7 +102,7 @@ export default function ForecastPage() {
{!loading && !error && data && (
<>
<div
className={`card flex items-center gap-3 ring-1 ${decisionStyle(data.decision)}`}
className={`flex items-center gap-3 rounded-lg p-4 border shadow-md ${decisionStyle(data.decision)}`}
data-tutorial-id="capacity-decision"
>
{decisionIcon(data.decision)}
@ -128,28 +128,33 @@ export default function ForecastPage() {
<div className="card" data-tutorial-id="forecast-canvas">
<div className="mb-2 flex items-baseline justify-between">
<h3 className="text-sm font-semibold text-slate-700">Active + Exiting Assets</h3>
<span className="text-xs text-slate-500">
<h3 className="text-sm font-semibold text-slate-200">Active + Exiting Assets</h3>
<span className="text-xs text-slate-400">
Headcount baseline: {data.totals.baselineHeadcount}
</span>
</div>
<div className="h-80 w-full">
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} unit="%" />
<Tooltip />
<Legend />
<Bar yAxisId="left" dataKey="active" stackId="a" name="Active assets" fill="#2563eb" />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<YAxis yAxisId="left" tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12, fill: '#94a3b8' }} stroke="#475569" unit="%" />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar yAxisId="left" dataKey="active" stackId="a" name="Active assets" fill="#6366f1" />
<Bar yAxisId="left" dataKey="exiting" stackId="a" name="Exiting assets" fill="#f59e0b" />
<Line
yAxisId="right"
type="monotone"
dataKey="exitRate"
name="Exit rate %"
stroke="#dc2626"
stroke="#f87171"
strokeWidth={2}
dot
/>
@ -169,11 +174,11 @@ export default function ForecastPage() {
</div>
<div className="card">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Week-by-week breakdown</h3>
<h3 className="mb-2 text-sm font-semibold text-slate-200">Week-by-week breakdown</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<tr className="border-b border-slate-700 text-left text-xs uppercase tracking-wide text-slate-400">
<th className="py-2 pr-2">Week</th>
<th className="py-2 pr-2">Starts</th>
<th className="py-2 pr-2 text-right">Active</th>
@ -186,15 +191,15 @@ export default function ForecastPage() {
</thead>
<tbody>
{data.weeks.map((w) => (
<tr key={w.weekStart} className="border-b border-slate-100">
<td className="py-1.5 pr-2 font-medium text-slate-800">{w.weekLabel}</td>
<td className="py-1.5 pr-2 text-slate-500">{w.weekStart}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.activeAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitingAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.exitRatePct}%</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.weeklyThroughput}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.deptCapacityAssetsPerWeek}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{w.canTakeOn}</td>
<tr key={w.weekStart} className="border-b border-slate-800">
<td className="py-1.5 pr-2 font-medium text-slate-100">{w.weekLabel}</td>
<td className="py-1.5 pr-2 text-slate-400">{w.weekStart}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.activeAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.exitingAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.exitRatePct}%</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.weeklyThroughput}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.deptCapacityAssetsPerWeek}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{w.canTakeOn}</td>
</tr>
))}
</tbody>

View file

@ -42,18 +42,18 @@ export default function Login() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-100 px-4">
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-md ring-1 ring-slate-200">
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<div className="w-full max-w-sm rounded-lg bg-slate-900 p-6 shadow-md border border-slate-800">
<div className="mb-6 text-center">
<div className="mx-auto mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-white">
<div className="mx-auto mb-2 inline-flex h-10 w-10 items-center justify-center rounded-full bg-indigo-600 text-white">
<LogIn className="h-5 w-5" aria-hidden />
</div>
<h1 className="text-lg font-semibold text-slate-900">L&apos;Oréal Utilisation</h1>
<p className="text-xs text-slate-500">Sign in to continue</p>
<h1 className="text-lg font-semibold text-white">L&apos;Oréal Utilisation</h1>
<p className="text-xs text-slate-400">Sign in to continue</p>
</div>
{rateLimited && (
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-900">
<div className="mb-3 rounded-md border border-amber-500/50 bg-amber-900/30 p-2 text-xs text-amber-200">
Too many login attempts. Please wait a few minutes and try again.
</div>
)}
@ -85,7 +85,7 @@ export default function Login() {
</div>
{fieldError && (
<div className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-700">{fieldError}</div>
<div className="rounded-md border border-red-500/50 bg-red-900/30 px-2 py-1 text-xs text-red-300">{fieldError}</div>
)}
<button type="submit" disabled={submitting} className="btn-primary w-full justify-center">

View file

@ -84,15 +84,15 @@ export default function ProjectTypeSummaryPage() {
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Project Type Summary</h1>
<p className="mt-1 text-xs text-slate-500">
<h1 className="text-base font-semibold text-slate-100">Project Type Summary</h1>
<p className="mt-1 text-xs text-slate-400">
Effort, duration and hour distribution per project type. Sort columns to find concentration
risks and outliers.
</p>
</section>
{!timelog.hash && (
<div className="card text-sm text-slate-600">
<div className="card text-sm text-slate-300">
Upload a time log to compute project-type benchmarks.
</div>
)}
@ -103,16 +103,21 @@ export default function ProjectTypeSummaryPage() {
{!loading && !error && data && data.stats.length > 0 && (
<>
<div className="card">
<h3 className="mb-2 text-sm font-semibold text-slate-700">Hours / asset by type</h3>
<h3 className="mb-2 text-sm font-semibold text-slate-200">Hours / asset by type</h3>
<div className="h-64 w-full">
<ResponsiveContainer>
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} angle={-15} textAnchor="end" height={60} />
<YAxis yAxisId="left" tick={{ fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#94a3b8' }} angle={-15} textAnchor="end" height={60} stroke="#475569" />
<YAxis yAxisId="left" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#475569" />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 11, fill: '#94a3b8' }} stroke="#475569" />
<Tooltip
contentStyle={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0' }}
itemStyle={{ color: '#e2e8f0' }}
labelStyle={{ color: '#f1f5f9' }}
cursor={{ fill: 'rgba(99, 102, 241, 0.1)' }}
/>
<Legend wrapperStyle={{ color: '#cbd5e1' }} />
<Bar yAxisId="left" dataKey="hoursPerAsset" name="h/asset" fill="#6366f1" />
<Line
yAxisId="right"
@ -128,14 +133,14 @@ export default function ProjectTypeSummaryPage() {
</div>
<div className="card" data-tutorial-id="project-type-table">
<h3 className="mb-2 text-sm font-semibold text-slate-700">
<h3 className="mb-2 text-sm font-semibold text-slate-200">
{data.totals.totalTypes} types · {data.totals.totalProjects} projects ·{' '}
{data.totals.totalHours}h
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
<tr className="border-b border-slate-700 text-left text-xs uppercase tracking-wide text-slate-400">
<Th k="projectType" l="Project type" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<Th k="projectCount" l="# projects" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
<Th k="totalHours" l="Total hours" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} right />
@ -148,15 +153,15 @@ export default function ProjectTypeSummaryPage() {
</thead>
<tbody>
{sortedStats.map((s) => (
<tr key={s.projectType} className="border-b border-slate-100">
<td className="py-1.5 pr-2 font-medium text-slate-800">{s.projectType}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.projectCount}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalHours}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.totalAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgHoursPerAsset}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.avgDurationDays}</td>
<td className="py-1.5 pr-2 text-right tabular-nums">{s.concentrationPct}%</td>
<td className="py-1.5 pr-2 text-xs text-slate-500">{s.autoInsight}</td>
<tr key={s.projectType} className="border-b border-slate-800">
<td className="py-1.5 pr-2 font-medium text-slate-100">{s.projectType}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.projectCount}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.totalHours}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.totalAssets}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.avgHoursPerAsset}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.avgDurationDays}</td>
<td className="py-1.5 pr-2 text-right tabular-nums text-slate-200">{s.concentrationPct}%</td>
<td className="py-1.5 pr-2 text-xs text-slate-400">{s.autoInsight}</td>
</tr>
))}
</tbody>
@ -188,7 +193,7 @@ function Th({
return (
<th
onClick={() => onSort(k)}
className={`cursor-pointer py-2 pr-2 hover:text-slate-700 select-none ${right ? 'text-right' : ''}`}
className={`cursor-pointer py-2 pr-2 hover:text-slate-200 select-none ${right ? 'text-right' : ''}`}
>
{l}
{arrow}

View file

@ -128,9 +128,9 @@ export default function Resourcing() {
/>
<div className="flex items-center gap-2">
{selectedPeriod && (
<div className="text-sm text-slate-600">
Drilled into <strong>{selectedPeriod}</strong>{' '}
<button onClick={() => setSelectedPeriod(null)} className="ml-1 text-blue-600 hover:underline">
<div className="text-sm text-slate-300">
Drilled into <strong className="text-slate-100">{selectedPeriod}</strong>{' '}
<button onClick={() => setSelectedPeriod(null)} className="ml-1 text-indigo-400 hover:text-indigo-300 hover:underline">
clear
</button>
</div>

View file

@ -99,8 +99,8 @@ export default function TimeLogDetailPage() {
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-base font-semibold text-slate-800">Time Log Detail</h1>
<p className="mt-1 text-xs text-slate-500">
<h1 className="text-base font-semibold text-slate-100">Time Log Detail</h1>
<p className="mt-1 text-xs text-slate-400">
Every parsed time entry. Server-side search and sort across the upload the table below
is virtualised so it stays smooth at 100k+ rows.
</p>
@ -117,7 +117,7 @@ export default function TimeLogDetailPage() {
placeholder="Search name, role, project, brand…"
className="input max-w-xs"
/>
<span className="ml-auto text-xs text-slate-500">
<span className="ml-auto text-xs text-slate-400">
{total.toLocaleString()} entries · page {page} / {totalPages}
</span>
<button
@ -144,13 +144,13 @@ export default function TimeLogDetailPage() {
{!loading && !error && (
<div className="overflow-x-auto">
<div
className="grid border-b border-slate-200 bg-slate-50 px-2 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
className="grid border-b border-slate-700 bg-slate-800 px-2 py-2 text-[11px] font-semibold uppercase tracking-wide text-slate-300"
style={{ gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
>
{COLS.map((c) => (
<div
key={String(c.key)}
className={`cursor-pointer select-none ${c.numeric ? 'text-right' : ''}`}
className={`cursor-pointer select-none hover:text-slate-100 ${c.numeric ? 'text-right' : ''}`}
onClick={() => handleSort(c.key)}
>
{c.label}
@ -170,7 +170,7 @@ export default function TimeLogDetailPage() {
<div
key={`${row.date ?? ''}-${row.submitter ?? ''}-${row.projectNumber ?? ''}-${startIndex + i}`}
style={{ height: ROW_HEIGHT, gridTemplateColumns: `repeat(${COLS.length}, minmax(0, 1fr))` }}
className="grid items-center border-b border-slate-100 px-2 text-xs text-slate-700 hover:bg-slate-50"
className="grid items-center border-b border-slate-800 px-2 text-xs text-slate-300 hover:bg-slate-800/50"
>
{COLS.map((c) => {
const v = row[c.key];
@ -187,7 +187,7 @@ export default function TimeLogDetailPage() {
</div>
))}
{rows.length === 0 && (
<div className="p-6 text-center text-sm text-slate-500">
<div className="p-6 text-center text-sm text-slate-400">
{timelog.hash ? 'No rows match the current search.' : 'Upload a time log to see rows.'}
</div>
)}

View file

@ -10,8 +10,8 @@ export default function Tutorial() {
return (
<div className="space-y-4">
<section className="card">
<h1 className="text-lg font-semibold text-slate-900">Tutorial Interactive Walkthrough</h1>
<p className="mt-1 text-sm text-slate-600">
<h1 className="text-lg font-semibold text-white">Tutorial Interactive Walkthrough</h1>
<p className="mt-1 text-sm text-slate-400">
A 9-step tour of the entire dashboard. The overlay only highlights elements that exist on
the current page start the tour, then navigate to other tabs to continue. The original
video walkthrough has been retired.
@ -27,11 +27,11 @@ export default function Tutorial() {
</section>
<div className="card">
<h2 className="text-sm font-semibold text-slate-800">Chapter list</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-600">
<h2 className="text-sm font-semibold text-slate-100">Chapter list</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-slate-400">
{globalSteps.map((step) => (
<li key={step.selector}>
<strong className="text-slate-800">{step.title}:</strong> {step.description}
<strong className="text-slate-100">{step.title}:</strong> {step.description}
</li>
))}
</ol>