fix: lift uploads into always-visible header tray; preserve state across navigation

Two bugs you pointed out (Time Log Detail / Project Type Summary had no
way to upload anything, yet were the pages that need an upload to show
data):

1. The big UploadButton lived only on Department. Moved a compact
   HeaderUploads tray into the Navbar — three slots (Time Log /
   Deliverable / Project Summary), each showing filename + row count
   when loaded, "Upload" / "Replace" / clear actions, drag-and-drop
   removed in favour of the simpler picker since the tray is small.

2. DataProvider was inside <ProtectedShell>, which is rendered per
   Route. Every nav click remounted it and silently wiped the uploaded
   timelog state, which is why Time Log Detail showed "Upload a time
   log to see rows" even after a Department upload. Lifted DataProvider
   to main.tsx, above <Routes>. Upload state now survives navigation.

3. Department page lost its duplicate big UploadButton (the tray
   handles it now) — kept the parser-warning banner inline since it's
   still useful context next to the filter bar.

typecheck / lint / build clean. No bundle-size regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-05-17 21:59:36 -04:00
parent 6e7338de99
commit dfbc57b22f
5 changed files with 170 additions and 39 deletions

View file

@ -6,7 +6,6 @@ import StatsBar from './components/StatsBar';
import ChatToggle from './components/ChatToggle';
import Loading from './components/Loading';
import Login from './pages/Login';
import { DataProvider } from './hooks/useDataContext';
import { canAccess, useAuth } from './hooks/useAuth';
const Department = lazy(() => import('./pages/Department'));
@ -28,18 +27,16 @@ function RoleGate({ slug, children }: { slug: string; children: React.ReactNode
function ProtectedShell({ children, slug }: { children: React.ReactNode; slug: string }) {
return (
<AuthGate>
<DataProvider>
<RoleGate slug={slug}>
<div className="flex min-h-screen flex-col">
<Navbar />
<StatsBar />
<main className="mx-auto w-full max-w-7xl flex-1 p-4 md:p-6">
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
</main>
<ChatToggle />
</div>
</RoleGate>
</DataProvider>
<RoleGate slug={slug}>
<div className="flex min-h-screen flex-col">
<Navbar />
<StatsBar />
<main className="mx-auto w-full max-w-7xl flex-1 p-4 md:p-6">
<Suspense fallback={<Loading label="Loading view…" />}>{children}</Suspense>
</main>
<ChatToggle />
</div>
</RoleGate>
</AuthGate>
);
}

View file

@ -0,0 +1,128 @@
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>
);
}
export default function HeaderUploads() {
const {
timelog, deliverable, projectSummary,
uploadTimelog, uploadDeliverable, uploadProjectSummary,
clearTimelog,
} = useDataContext();
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>
);
}

View file

@ -1,6 +1,7 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LogOut, BarChart3 } from 'lucide-react';
import { canAccess, useAuth } from '../hooks/useAuth';
import HeaderUploads from './HeaderUploads';
interface Tab {
to: string;
@ -74,6 +75,12 @@ export default function Navbar() {
</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">
<HeaderUploads />
</div>
</div>
</header>
);
}

View file

@ -3,15 +3,21 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './hooks/useAuth';
import { DataProvider } from './hooks/useDataContext';
import './index.css';
const basename = import.meta.env.BASE_URL.replace(/\/$/, '');
// DataProvider lives at the root so the uploaded timelog / deliverable /
// project-summary state survives navigation between tabs. (Previously it
// was per-route, which silently wiped your upload on every nav click.)
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter basename={basename}>
<AuthProvider>
<App />
<DataProvider>
<App />
</DataProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,

View file

@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Download, RefreshCw } from 'lucide-react';
import FilterBar from '../components/FilterBar';
import UploadButton from '../components/UploadButton';
import KpiTiles from '../components/KpiTiles';
import PeriodToggle from '../components/PeriodToggle';
import HourBreakdown from '../components/HourBreakdown';
@ -21,7 +20,7 @@ import type { UtilisationSummaryRow, UtilisationTotals } from '../api/types';
export default function Department() {
const airtable = useAirtableData(false);
const { filters, dispatch, timelog, dimensions, uploadTimelog, clearTimelog } = useDataContext();
const { filters, dispatch, timelog, dimensions } = useDataContext();
const [summary, setSummary] = useState<UtilisationSummaryRow[]>([]);
const [totals, setTotals] = useState<UtilisationTotals | null>(null);
const [summaryLoading, setSummaryLoading] = useState(false);
@ -97,29 +96,23 @@ export default function Department() {
</ol>
</section>
<div className="grid gap-4 md:grid-cols-2">
<UploadButton
uploading={timelog.uploading}
error={timelog.error}
unrecognised={timelog.unrecognised}
filename={timelog.filename}
rowCount={timelog.rows}
onFile={(f) => void uploadTimelog(f)}
onClear={clearTimelog}
/>
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
userRoles={dimensions.userRoles}
/>
</div>
<FilterBar
state={filters}
dispatch={dispatch}
departments={airtable.meta?.departments ?? []}
names={names}
billingTypes={airtable.meta?.billingTypes ?? []}
brands={dimensions.brands}
divisions={dimensions.divisions}
hubs={dimensions.hubs}
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">
<strong>Time log is missing expected column{timelog.unrecognised.length > 1 ? 's' : ''}:</strong>{' '}
{timelog.unrecognised.join(', ')} charts may be incomplete.
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<PeriodToggle