From dfbc57b22fd651f20aa892d048a36e617042109d Mon Sep 17 00:00:00 2001 From: DJP Date: Sun, 17 May 2026 21:59:36 -0400 Subject: [PATCH] fix: lift uploads into always-visible header tray; preserve state across navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 . 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) --- frontend/src/App.tsx | 23 ++-- frontend/src/components/HeaderUploads.tsx | 128 ++++++++++++++++++++++ frontend/src/components/Navbar.tsx | 7 ++ frontend/src/main.tsx | 8 +- frontend/src/pages/Department.tsx | 43 +++----- 5 files changed, 170 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/HeaderUploads.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f576176..96c43d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - -
- - -
- }>{children} -
- -
-
-
+ +
+ + +
+ }>{children} +
+ +
+
); } diff --git a/frontend/src/components/HeaderUploads.tsx b/frontend/src/components/HeaderUploads.tsx new file mode 100644 index 0000000..d32446d --- /dev/null +++ b/frontend/src/components/HeaderUploads.tsx @@ -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(null); + + const onChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) onFile(f); + e.target.value = ''; + }; + + const status = uploading + ? 'uploading' + : error + ? 'error' + : filename + ? 'loaded' + : 'empty'; + + return ( +
+ {uploading ? ( + + ) : ( + + )} + +
+ {label} + {status === 'loaded' && ( + + {filename} · {rows.toLocaleString()} rows + + )} + {status === 'uploading' && Parsing…} + {status === 'empty' && Not uploaded} + {status === 'error' && {error}} +
+ + + + {filename && onClear && ( + + )} +
+ ); +} + +export default function HeaderUploads() { + const { + timelog, deliverable, projectSummary, + uploadTimelog, uploadDeliverable, uploadProjectSummary, + clearTimelog, + } = useDataContext(); + + return ( +
+ void uploadTimelog(f)} + onClear={clearTimelog} + /> + void uploadDeliverable(f)} + /> + void uploadProjectSummary(f)} + /> +
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 5ac8562..124f865 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -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() { + {/* Compact upload tray — always visible so every data page can feed it. */} +
+
+ +
+
); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index cc4488c..1881355 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + + , diff --git a/frontend/src/pages/Department.tsx b/frontend/src/pages/Department.tsx index c96fab6..eb2f11a 100644 --- a/frontend/src/pages/Department.tsx +++ b/frontend/src/pages/Department.tsx @@ -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([]); const [totals, setTotals] = useState(null); const [summaryLoading, setSummaryLoading] = useState(false); @@ -97,29 +96,23 @@ export default function Department() { -
- void uploadTimelog(f)} - onClear={clearTimelog} - /> - - -
+ + {timelog.unrecognised.length > 0 && ( +
+ Time log is missing expected column{timelog.unrecognised.length > 1 ? 's' : ''}:{' '} + {timelog.unrecognised.join(', ')} — charts may be incomplete. +
+ )}