Skip to content

Commit 2b8aee3

Browse files
Integrate live operators UI
Add LiveOperatorsDashboard, useLiveOperators hook, and route wiring; enable real-time live floor status with per-cell breakdown and OEE metrics. Update analytics barrel and App routes to include EmployeeOEEDashboard and LiveOperatorsDashboard, and implement missing import wiring for new analytics pages. X-Lovable-Edit-ID: edt-ec65ecaf-fa19-4939-8679-d952d7b988a3
2 parents b7b5774 + 7d07f6f commit 2b8aee3

File tree

4 files changed

+700
-0
lines changed

4 files changed

+700
-0
lines changed

src/App.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ const ConfigWebhooks = lazy(() => import("./pages/admin/config/Webhooks"));
6666

6767
// Admin analytics pages - lazy loaded
6868
const OEEAnalytics = lazy(() => import("./pages/admin/analytics/OEEAnalytics"));
69+
const EmployeeOEEDashboard = lazy(() => import("./pages/admin/analytics/EmployeeOEEDashboard"));
70+
const LiveOperatorsDashboard = lazy(() => import("./pages/admin/analytics/LiveOperatorsDashboard"));
6971
const ReliabilityAnalytics = lazy(() => import("./pages/admin/analytics/ReliabilityAnalytics"));
7072
const QRMAnalytics = lazy(() => import("./pages/admin/analytics/QRMAnalytics"));
7173
const QRMDashboard = lazy(() => import("./pages/admin/analytics/QRMDashboard"));
@@ -699,6 +701,32 @@ function AppRoutes() {
699701
}
700702
/>
701703

704+
<Route
705+
path="/admin/analytics/employee-oee"
706+
element={
707+
<ProtectedRoute adminOnly>
708+
<Layout>
709+
<LazyRoute>
710+
<EmployeeOEEDashboard />
711+
</LazyRoute>
712+
</Layout>
713+
</ProtectedRoute>
714+
}
715+
/>
716+
717+
<Route
718+
path="/admin/analytics/live-operators"
719+
element={
720+
<ProtectedRoute adminOnly>
721+
<Layout>
722+
<LazyRoute>
723+
<LiveOperatorsDashboard />
724+
</LazyRoute>
725+
</Layout>
726+
</ProtectedRoute>
727+
}
728+
/>
729+
702730
<Route
703731
path="/admin/analytics/quality"
704732
element={

src/hooks/useLiveOperators.ts

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { supabase } from "@/integrations/supabase/client";
3+
import { useAuth } from "@/contexts/AuthContext";
4+
import { useEffect } from "react";
5+
6+
export interface LiveOperator {
7+
id: string;
8+
operatorId: string;
9+
operatorName: string;
10+
employeeId: string;
11+
status: "clocked_in" | "on_job" | "idle";
12+
clockInTime: string;
13+
currentCell?: {
14+
id: string;
15+
name: string;
16+
color: string | null;
17+
} | null;
18+
currentJob?: {
19+
jobNumber: string;
20+
partNumber: string;
21+
operationName: string;
22+
startTime: string;
23+
} | null;
24+
todayStats: {
25+
partsProduced: number;
26+
goodParts: number;
27+
scrapParts: number;
28+
reworkParts: number;
29+
hoursWorked: number;
30+
};
31+
}
32+
33+
export interface LiveOperatorsData {
34+
operators: LiveOperator[];
35+
summary: {
36+
totalClockedIn: number;
37+
totalOnJob: number;
38+
totalIdle: number;
39+
totalPartsToday: number;
40+
totalGoodParts: number;
41+
totalScrapParts: number;
42+
qualityRate: number;
43+
};
44+
byCell: {
45+
cellId: string;
46+
cellName: string;
47+
cellColor: string | null;
48+
operators: LiveOperator[];
49+
partsProduced: number;
50+
goodParts: number;
51+
}[];
52+
}
53+
54+
export function useLiveOperators() {
55+
const { profile } = useAuth();
56+
57+
const query = useQuery({
58+
queryKey: ["live-operators", profile?.tenant_id],
59+
queryFn: async (): Promise<LiveOperatorsData> => {
60+
if (!profile?.tenant_id) {
61+
throw new Error("No tenant ID");
62+
}
63+
64+
const now = new Date();
65+
const startOfToday = new Date(now);
66+
startOfToday.setHours(0, 0, 0, 0);
67+
68+
// Fetch active attendance entries (clocked in)
69+
const { data: attendance, error: attError } = await supabase
70+
.from("attendance_entries")
71+
.select(`
72+
id,
73+
operator_id,
74+
clock_in,
75+
operator:operators!inner(id, full_name, employee_id)
76+
`)
77+
.eq("tenant_id", profile.tenant_id)
78+
.eq("status", "active")
79+
.is("clock_out", null);
80+
81+
if (attError) throw attError;
82+
83+
// Fetch active time entries (on job)
84+
const { data: timeEntries, error: teError } = await supabase
85+
.from("time_entries")
86+
.select(`
87+
id,
88+
operator_id,
89+
start_time,
90+
operation:operations!inner(
91+
id,
92+
operation_name,
93+
cell:cells!inner(id, name, color),
94+
part:parts!inner(
95+
part_number,
96+
job:jobs!inner(job_number)
97+
)
98+
)
99+
`)
100+
.eq("tenant_id", profile.tenant_id)
101+
.is("end_time", null);
102+
103+
if (teError) throw teError;
104+
105+
// Fetch today's production quantities
106+
const { data: quantities, error: qError } = await supabase
107+
.from("operation_quantities")
108+
.select(`
109+
quantity_produced,
110+
quantity_good,
111+
quantity_scrap,
112+
quantity_rework,
113+
recorded_by,
114+
operation:operations!inner(
115+
cell:cells!inner(id, name, color)
116+
)
117+
`)
118+
.eq("tenant_id", profile.tenant_id)
119+
.gte("recorded_at", startOfToday.toISOString());
120+
121+
if (qError) throw qError;
122+
123+
// Fetch all active cells
124+
const { data: cells, error: cellsError } = await supabase
125+
.from("cells")
126+
.select("id, name, color")
127+
.eq("tenant_id", profile.tenant_id)
128+
.eq("active", true)
129+
.order("sequence");
130+
131+
if (cellsError) throw cellsError;
132+
133+
// Build time entries map by operator
134+
const timeEntryMap = new Map<string, any>();
135+
(timeEntries || []).forEach((te: any) => {
136+
timeEntryMap.set(te.operator_id, te);
137+
});
138+
139+
// Build production stats by operator
140+
const productionByOperator = new Map<string, {
141+
partsProduced: number;
142+
goodParts: number;
143+
scrapParts: number;
144+
reworkParts: number;
145+
}>();
146+
147+
(quantities || []).forEach((q: any) => {
148+
const recordedBy = q.recorded_by;
149+
if (!recordedBy) return;
150+
const existing = productionByOperator.get(recordedBy) || {
151+
partsProduced: 0,
152+
goodParts: 0,
153+
scrapParts: 0,
154+
reworkParts: 0,
155+
};
156+
existing.partsProduced += q.quantity_produced || 0;
157+
existing.goodParts += q.quantity_good || 0;
158+
existing.scrapParts += q.quantity_scrap || 0;
159+
existing.reworkParts += q.quantity_rework || 0;
160+
productionByOperator.set(recordedBy, existing);
161+
});
162+
163+
// Build production stats by cell
164+
const productionByCell = new Map<string, { partsProduced: number; goodParts: number }>();
165+
(quantities || []).forEach((q: any) => {
166+
const cellId = q.operation?.cell?.id;
167+
if (!cellId) return;
168+
const existing = productionByCell.get(cellId) || { partsProduced: 0, goodParts: 0 };
169+
existing.partsProduced += q.quantity_produced || 0;
170+
existing.goodParts += q.quantity_good || 0;
171+
productionByCell.set(cellId, existing);
172+
});
173+
174+
// Map operators
175+
const operators: LiveOperator[] = (attendance || []).map((a: any) => {
176+
const operatorId = a.operator?.id || a.operator_id;
177+
const timeEntry = timeEntryMap.get(operatorId);
178+
const production = productionByOperator.get(operatorId) || {
179+
partsProduced: 0,
180+
goodParts: 0,
181+
scrapParts: 0,
182+
reworkParts: 0,
183+
};
184+
185+
const clockIn = new Date(a.clock_in);
186+
const hoursWorked = (now.getTime() - clockIn.getTime()) / 1000 / 60 / 60;
187+
188+
let status: LiveOperator["status"] = "clocked_in";
189+
let currentCell: LiveOperator["currentCell"] = null;
190+
let currentJob: LiveOperator["currentJob"] = null;
191+
192+
if (timeEntry) {
193+
status = "on_job";
194+
currentCell = {
195+
id: timeEntry.operation.cell.id,
196+
name: timeEntry.operation.cell.name,
197+
color: timeEntry.operation.cell.color,
198+
};
199+
currentJob = {
200+
jobNumber: timeEntry.operation.part.job.job_number,
201+
partNumber: timeEntry.operation.part.part_number,
202+
operationName: timeEntry.operation.operation_name,
203+
startTime: timeEntry.start_time,
204+
};
205+
} else {
206+
status = "idle";
207+
}
208+
209+
return {
210+
id: a.id,
211+
operatorId,
212+
operatorName: a.operator?.full_name || "Unknown",
213+
employeeId: a.operator?.employee_id || "",
214+
status,
215+
clockInTime: a.clock_in,
216+
currentCell,
217+
currentJob,
218+
todayStats: {
219+
...production,
220+
hoursWorked: Number(hoursWorked.toFixed(1)),
221+
},
222+
};
223+
});
224+
225+
// Calculate summary
226+
const totalClockedIn = operators.length;
227+
const totalOnJob = operators.filter((o) => o.status === "on_job").length;
228+
const totalIdle = operators.filter((o) => o.status === "idle").length;
229+
const totalPartsToday = operators.reduce((sum, o) => sum + o.todayStats.partsProduced, 0);
230+
const totalGoodParts = operators.reduce((sum, o) => sum + o.todayStats.goodParts, 0);
231+
const totalScrapParts = operators.reduce((sum, o) => sum + o.todayStats.scrapParts, 0);
232+
const qualityRate = totalPartsToday > 0 ? (totalGoodParts / totalPartsToday) * 100 : 100;
233+
234+
// Build by cell breakdown
235+
const byCell = (cells || []).map((cell: any) => {
236+
const cellOperators = operators.filter(
237+
(o) => o.currentCell?.id === cell.id
238+
);
239+
const cellProduction = productionByCell.get(cell.id) || { partsProduced: 0, goodParts: 0 };
240+
241+
return {
242+
cellId: cell.id,
243+
cellName: cell.name,
244+
cellColor: cell.color,
245+
operators: cellOperators,
246+
partsProduced: cellProduction.partsProduced,
247+
goodParts: cellProduction.goodParts,
248+
};
249+
});
250+
251+
// Add "Not on Cell" group for idle operators
252+
const idleOperators = operators.filter((o) => o.status === "idle");
253+
if (idleOperators.length > 0) {
254+
byCell.push({
255+
cellId: "idle",
256+
cellName: "Not on Cell",
257+
cellColor: null,
258+
operators: idleOperators,
259+
partsProduced: 0,
260+
goodParts: 0,
261+
});
262+
}
263+
264+
return {
265+
operators,
266+
summary: {
267+
totalClockedIn,
268+
totalOnJob,
269+
totalIdle,
270+
totalPartsToday,
271+
totalGoodParts,
272+
totalScrapParts,
273+
qualityRate: Number(qualityRate.toFixed(1)),
274+
},
275+
byCell,
276+
};
277+
},
278+
enabled: !!profile?.tenant_id,
279+
refetchInterval: 10000,
280+
staleTime: 5000,
281+
});
282+
283+
// Set up real-time subscription
284+
useEffect(() => {
285+
if (!profile?.tenant_id) return;
286+
287+
const channel = supabase
288+
.channel("live-operators-changes")
289+
.on(
290+
"postgres_changes",
291+
{
292+
event: "*",
293+
schema: "public",
294+
table: "attendance_entries",
295+
filter: `tenant_id=eq.${profile.tenant_id}`,
296+
},
297+
() => query.refetch()
298+
)
299+
.on(
300+
"postgres_changes",
301+
{
302+
event: "*",
303+
schema: "public",
304+
table: "time_entries",
305+
filter: `tenant_id=eq.${profile.tenant_id}`,
306+
},
307+
() => query.refetch()
308+
)
309+
.on(
310+
"postgres_changes",
311+
{
312+
event: "*",
313+
schema: "public",
314+
table: "operation_quantities",
315+
filter: `tenant_id=eq.${profile.tenant_id}`,
316+
},
317+
() => query.refetch()
318+
)
319+
.subscribe();
320+
321+
return () => {
322+
supabase.removeChannel(channel);
323+
};
324+
}, [profile?.tenant_id]);
325+
326+
return query;
327+
}

0 commit comments

Comments
 (0)