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:
parent
6e7338de99
commit
dfbc57b22f
5 changed files with 170 additions and 39 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
128
frontend/src/components/HeaderUploads.tsx
Normal file
128
frontend/src/components/HeaderUploads.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue