From f218730cf308c01f50d5a4c088d2e692e46c3c9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 11:48:14 +0000 Subject: [PATCH] Add persistent session tracking bottom bar for all views - Create new SessionTrackingBar component with glassmorphism design - Show active time entries with pulsing indicator - Support pause/resume/stop actions directly from the bar - Display timer, operation name, job number, and part number - Add expand/collapse for multiple concurrent sessions - Include minimize/dismiss functionality - Integrate into both AdminLayout and OperatorLayout - Add translations for EN, DE, and NL --- src/components/SessionTrackingBar.tsx | 482 ++++++++++++++++++++++++++ src/i18n/locales/de/translation.json | 16 + src/i18n/locales/en/translation.json | 16 + src/i18n/locales/nl/translation.json | 16 + src/layouts/AdminLayout.tsx | 6 +- src/layouts/OperatorLayout.tsx | 6 +- 6 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 src/components/SessionTrackingBar.tsx diff --git a/src/components/SessionTrackingBar.tsx b/src/components/SessionTrackingBar.tsx new file mode 100644 index 00000000..93b15878 --- /dev/null +++ b/src/components/SessionTrackingBar.tsx @@ -0,0 +1,482 @@ +import { useEffect, useState, useCallback } from "react"; +import { useAuth } from "@/contexts/AuthContext"; +import { supabase } from "@/integrations/supabase/client"; +import { Button } from "@/components/ui/button"; +import { Clock, Square, Pause, Play, ChevronUp, ChevronDown, X } from "lucide-react"; +import { stopTimeTracking, pauseTimeTracking, resumeTimeTracking } from "@/lib/database"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; + +interface ActiveEntry { + id: string; + operation_id: string; + start_time: string; + is_paused: boolean; + operation: { + operation_name: string; + part: { + part_number: string; + job: { + job_number: string; + }; + }; + }; +} + +interface PauseData { + paused_at: string; +} + +export default function SessionTrackingBar() { + const { t } = useTranslation(); + const { profile } = useAuth(); + const [activeEntries, setActiveEntries] = useState([]); + const [currentPauses, setCurrentPauses] = useState>({}); + const [elapsedSeconds, setElapsedSeconds] = useState>({}); + const [loading, setLoading] = useState>({}); + const [expanded, setExpanded] = useState(false); + const [dismissed, setDismissed] = useState(false); + + const loadCurrentPauses = async (entries: ActiveEntry[]) => { + const pausedEntries = entries.filter(e => e.is_paused); + if (pausedEntries.length === 0) { + setCurrentPauses({}); + return; + } + + const pausePromises = pausedEntries.map(async (entry) => { + const { data } = await supabase + .from("time_entry_pauses") + .select("paused_at") + .eq("time_entry_id", entry.id) + .is("resumed_at", null) + .maybeSingle(); + return { entryId: entry.id, pause: data }; + }); + + const results = await Promise.all(pausePromises); + const newPauses: Record = {}; + results.forEach(({ entryId, pause }) => { + if (pause) { + newPauses[entryId] = pause; + } + }); + setCurrentPauses(newPauses); + }; + + const loadActiveEntries = useCallback(async () => { + if (!profile?.id) return; + + const { data, error } = await supabase + .from("time_entries") + .select( + ` + id, + operation_id, + start_time, + is_paused, + operation:operations( + operation_name, + part:parts( + part_number, + job:jobs(job_number) + ) + ) + ` + ) + .eq("operator_id", profile.id) + .is("end_time", null); + + if (!error && data) { + setActiveEntries(data as any); + loadCurrentPauses(data as any); + // Reset dismissed when new entries appear + if (data.length > 0 && dismissed) { + setDismissed(false); + } + } else { + setActiveEntries([]); + setCurrentPauses({}); + } + }, [profile?.id, dismissed]); + + // Calculate elapsed time for each entry + useEffect(() => { + const calculateElapsed = () => { + const now = new Date().getTime(); + const newElapsed: Record = {}; + + activeEntries.forEach(entry => { + if (entry.is_paused && currentPauses[entry.id]) { + // For paused entries, calculate time until pause + const pausedAt = new Date(currentPauses[entry.id].paused_at).getTime(); + const startTime = new Date(entry.start_time).getTime(); + newElapsed[entry.id] = Math.floor((pausedAt - startTime) / 1000); + } else { + // For active entries, calculate from start to now + const startTime = new Date(entry.start_time).getTime(); + newElapsed[entry.id] = Math.floor((now - startTime) / 1000); + } + }); + + setElapsedSeconds(newElapsed); + }; + + calculateElapsed(); + const interval = setInterval(calculateElapsed, 1000); + return () => clearInterval(interval); + }, [activeEntries, currentPauses]); + + useEffect(() => { + if (!profile?.id) return; + + loadActiveEntries(); + + // Subscribe to time entries changes + const channel = supabase + .channel("session-tracking-bar") + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "time_entries", + filter: `operator_id=eq.${profile.id}`, + }, + () => { + loadActiveEntries(); + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [profile?.id, loadActiveEntries]); + + const handleStop = async (entry: ActiveEntry) => { + if (!profile?.id) return; + + setLoading(prev => ({ ...prev, [entry.id]: true })); + try { + await stopTimeTracking(entry.operation_id, profile.id); + toast.success(t("sessionTracking.stopped")); + loadActiveEntries(); + } catch (error: any) { + toast.error(error.message || t("sessionTracking.stopFailed")); + } finally { + setLoading(prev => ({ ...prev, [entry.id]: false })); + } + }; + + const handlePause = async (entry: ActiveEntry) => { + if (!profile?.id) return; + + setLoading(prev => ({ ...prev, [entry.id]: true })); + try { + await pauseTimeTracking(entry.id); + toast.success(t("sessionTracking.paused")); + loadActiveEntries(); + } catch (error: any) { + toast.error(error.message || t("sessionTracking.pauseFailed")); + } finally { + setLoading(prev => ({ ...prev, [entry.id]: false })); + } + }; + + const handleResume = async (entry: ActiveEntry) => { + if (!profile?.id) return; + + setLoading(prev => ({ ...prev, [entry.id]: true })); + try { + await resumeTimeTracking(entry.id); + toast.success(t("sessionTracking.resumed")); + loadActiveEntries(); + } catch (error: any) { + toast.error(error.message || t("sessionTracking.resumeFailed")); + } finally { + setLoading(prev => ({ ...prev, [entry.id]: false })); + } + }; + + const formatTime = (seconds: number) => { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hrs > 0) { + return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // Don't render if no active entries or dismissed + if (activeEntries.length === 0 || dismissed) return null; + + const primaryEntry = activeEntries[0]; + const hasMultiple = activeEntries.length > 1; + + return ( +
+ {/* Glassmorphism Bottom Bar */} +
+ {/* Main Bar Content */} +
+
+ {/* Left: Status Indicator + Info */} +
+ {/* Pulsing Indicator */} +
+
+ +
+ {/* Pulse animation for active tracking */} + {!primaryEntry.is_paused && ( + + + + + )} +
+ + {/* Task Info */} +
+
+ + {t("sessionTracking.currentlyTracking")} + + {primaryEntry.is_paused && ( + + {t("sessionTracking.paused")} + + )} +
+
+ {primaryEntry.operation.operation_name} +
+
+ {t("sessionTracking.task")} {primaryEntry.operation.part.job.job_number} • {primaryEntry.operation.part.part_number} +
+
+ + {/* Timer Display */} +
+
+ {formatTime(elapsedSeconds[primaryEntry.id] || 0)} +
+
+ {t("sessionTracking.elapsed")} +
+
+
+ + {/* Right: Action Buttons */} +
+ {/* Timer on mobile */} +
+ {formatTime(elapsedSeconds[primaryEntry.id] || 0)} +
+ + {/* Pause/Resume */} + {primaryEntry.is_paused ? ( + + ) : ( + + )} + + {/* Stop */} + + + {/* Expand/Collapse for multiple entries */} + {hasMultiple && ( + + )} + + {/* Dismiss (minimize) */} + +
+
+ + {/* Multiple entries indicator */} + {hasMultiple && !expanded && ( +
+ +
+ )} +
+ + {/* Expanded entries list */} + {expanded && hasMultiple && ( +
+ {activeEntries.slice(1).map((entry) => ( +
+
+
+
+ +
+
+
+ {entry.operation.operation_name} +
+
+ {entry.operation.part.job.job_number} • {entry.operation.part.part_number} +
+
+
+ {formatTime(elapsedSeconds[entry.id] || 0)} +
+
+
+ {entry.is_paused ? ( + + ) : ( + + )} + +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 14a202b0..67c8672e 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -1584,5 +1584,21 @@ "noScrapData": "Noch keine Ausschussdaten erfasst", "usage": "Verwendung", "scrapped": "Verschrottet" + }, + "sessionTracking": { + "currentlyTracking": "Aktuelle Zeiterfassung", + "task": "Auftrag", + "elapsed": "vergangen", + "paused": "Pausiert", + "pause": "Pause", + "resume": "Fortsetzen", + "stop": "Stoppen", + "stopped": "Zeiterfassung gestoppt", + "stopFailed": "Zeiterfassung konnte nicht gestoppt werden", + "pauseFailed": "Zeiterfassung konnte nicht pausiert werden", + "resumed": "Zeiterfassung fortgesetzt", + "resumeFailed": "Zeiterfassung konnte nicht fortgesetzt werden", + "minimize": "Minimieren", + "moreOperations": "weitere Arbeitsgang/Arbeitsgänge" } } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index ad1e99e3..915526a1 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -1740,5 +1740,21 @@ "calendar": { "title": "Factory Calendar", "description": "Manage holidays, closures, and special working days" + }, + "sessionTracking": { + "currentlyTracking": "Currently Tracking", + "task": "Task", + "elapsed": "elapsed", + "paused": "Paused", + "pause": "Pause", + "resume": "Resume", + "stop": "Stop", + "stopped": "Time tracking stopped", + "stopFailed": "Failed to stop time tracking", + "pauseFailed": "Failed to pause time tracking", + "resumed": "Time tracking resumed", + "resumeFailed": "Failed to resume time tracking", + "minimize": "Minimize", + "moreOperations": "more operation(s)" } } \ No newline at end of file diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 392e0f92..271dc61a 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -1857,5 +1857,21 @@ "noScrapData": "Nog geen afvalgegevens geregistreerd", "usage": "Gebruik", "scrapped": "Afgekeurd" + }, + "sessionTracking": { + "currentlyTracking": "Momenteel aan het bijhouden", + "task": "Taak", + "elapsed": "verstreken", + "paused": "Gepauzeerd", + "pause": "Pauzeren", + "resume": "Hervatten", + "stop": "Stoppen", + "stopped": "Tijdregistratie gestopt", + "stopFailed": "Tijdregistratie stoppen mislukt", + "pauseFailed": "Tijdregistratie pauzeren mislukt", + "resumed": "Tijdregistratie hervat", + "resumeFailed": "Tijdregistratie hervatten mislukt", + "minimize": "Minimaliseren", + "moreOperations": "meer bewerking(en)" } } diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index aefc3a2b..1abf91cb 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -50,6 +50,7 @@ import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ROUTES } from "@/routes"; import { useTranslation } from "react-i18next"; import AnimatedBackground from "@/components/AnimatedBackground"; +import SessionTrackingBar from "@/components/SessionTrackingBar"; interface AdminLayoutProps { children: React.ReactNode; @@ -611,11 +612,14 @@ export default function AdminLayout({ children }: AdminLayoutProps) { {/* Main Content */} -
+
{children}
+ + {/* Global Session Tracking Bar */} + ); diff --git a/src/layouts/OperatorLayout.tsx b/src/layouts/OperatorLayout.tsx index 92e25bdc..fbb1dec8 100644 --- a/src/layouts/OperatorLayout.tsx +++ b/src/layouts/OperatorLayout.tsx @@ -33,6 +33,7 @@ import CurrentlyTimingWidget from "@/components/operator/CurrentlyTimingWidget"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { AppTour } from "@/components/onboarding"; import { ROUTES } from "@/routes"; +import SessionTrackingBar from "@/components/SessionTrackingBar"; interface OperatorLayoutProps { children: React.ReactNode; @@ -228,7 +229,7 @@ export const OperatorLayout: React.FC = ({ children }) => { {/* Main Content */} -
+
{children}
@@ -266,6 +267,9 @@ export const OperatorLayout: React.FC = ({ children }) => { + {/* Global Session Tracking Bar */} + + {/* Onboarding Tour - only show if not completed */} {profile && !(profile as any).tour_completed && }