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:
parent
dfbc57b22f
commit
6320fb389c
29 changed files with 564 additions and 497 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(' ')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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'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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'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'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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue