diff --git a/src/App.tsx b/src/App.tsx index 7fb79609..a82d0484 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,8 @@ import OEEAnalytics from "./pages/admin/analytics/OEEAnalytics"; import ReliabilityAnalytics from "./pages/admin/analytics/ReliabilityAnalytics"; import QRMAnalytics from "./pages/admin/analytics/QRMAnalytics"; import QRMDashboard from "./pages/admin/analytics/QRMDashboard"; +import JobsAnalytics from "./pages/admin/analytics/JobsAnalytics"; +import QualityAnalytics from "./pages/admin/analytics/QualityAnalytics"; import ApiDocs from "./pages/ApiDocs"; import Pricing from "./pages/Pricing"; import { MyPlan } from "./pages/MyPlan"; @@ -522,6 +524,28 @@ function AppRoutes() { } /> + + + + + + } + /> + + + + + + + } + /> + } + {/* Analytics Section - Collapsible */} + {!collapsed && ( + + + + + + {analyticsNavItems.map((item) => { + const isItemActive = item.exact + ? isActive(item.path) + : location.pathname.startsWith(item.path); + + return ( + setMobileOpen(false)}> + + + ); + })} + + + )} + + {!collapsed && } + {/* Configuration Section - Collapsible */} {!collapsed && ( diff --git a/src/components/analytics/QRMDashboardCharts.tsx b/src/components/analytics/QRMDashboardCharts.tsx index 8c486024..58f21bb1 100644 --- a/src/components/analytics/QRMDashboardCharts.tsx +++ b/src/components/analytics/QRMDashboardCharts.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { memo } from "react"; import { AreaChart, Area, @@ -11,15 +11,13 @@ import { BarChart, Bar, Cell, - ComposedChart, - Line, PieChart, Pie, } from "recharts"; import { useTranslation } from "react-i18next"; import { QRMDashboardMetrics } from "@/hooks/useQRMDashboardMetrics"; -// --- Colors from Design System --- +// --- Centralized Chart Styling (Design System) --- const COLORS = { primary: "hsl(var(--brand-primary))", success: "hsl(var(--color-success))", @@ -30,19 +28,51 @@ const COLORS = { grid: "rgba(255,255,255,0.1)", }; +// Centralized tooltip style for consistency +const TOOLTIP_STYLE = { + contentStyle: { + backgroundColor: "hsl(var(--card))", + borderColor: "hsl(var(--border))", + color: "hsl(var(--foreground))", + borderRadius: "8px", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + }, + cursor: { fill: "rgba(255,255,255,0.05)" }, +}; + +// Centralized axis styling +const AXIS_STYLE = { + stroke: COLORS.muted, + fontSize: 12, + tickLine: false, + axisLine: false, +}; + +// WIP Age gradient colors +const AGE_COLORS = [ + COLORS.success, // 0-2 days + COLORS.info, // 3-5 days + COLORS.warning, // 6-10 days + COLORS.error, // >10 days +]; + // --- MCT Chart --- -export const MCTChart = ({ data }: { data: QRMDashboardMetrics["mct"] }) => { +export const MCTChart = memo(({ data }: { data: QRMDashboardMetrics["mct"] }) => { const { t } = useTranslation(); return ( -
+
{data.current.toFixed(1)} {t("qrm.mct.days")}
- + @@ -76,26 +106,26 @@ export const MCTChart = ({ data }: { data: QRMDashboardMetrics["mct"] }) => { fillOpacity={1} fill="url(#colorMct)" strokeWidth={2} + name={t("qrm.mct.title")} />
); +}); + +// Helper to get color based on performance value +const getPerformanceColor = (value: number) => { + if (value >= 95) return COLORS.success; + if (value >= 85) return COLORS.warning; + return COLORS.error; }; // --- OTP Gauge --- -export const OTPGauge = ({ data }: { data: QRMDashboardMetrics["otp"] }) => { +export const OTPGauge = memo(({ data }: { data: QRMDashboardMetrics["otp"] }) => { const { t } = useTranslation(); - - // Calculate color based on value - const getColor = (value: number) => { - if (value >= 95) return COLORS.success; - if (value >= 85) return COLORS.warning; - return COLORS.error; - }; - - const color = getColor(data.current); + const color = getPerformanceColor(data.current); // Data for the semi-circle gauge const gaugeData = [ @@ -104,10 +134,10 @@ export const OTPGauge = ({ data }: { data: QRMDashboardMetrics["otp"] }) => { ]; return ( -
+
- + { paddingAngle={0} dataKey="value" stroke="none" + name={t("qrm.otp.title")} > @@ -133,26 +164,27 @@ export const OTPGauge = ({ data }: { data: QRMDashboardMetrics["otp"] }) => { {/* Mini Trend Line */}
- +
); -}; +}); // --- Queue Time Chart --- -export const QueueTimeChart = ({ data }: { data: QRMDashboardMetrics["queueTime"] }) => { +export const QueueTimeChart = memo(({ data }: { data: QRMDashboardMetrics["queueTime"] }) => { const { t } = useTranslation(); return ( -
+
@@ -161,13 +193,9 @@ export const QueueTimeChart = ({ data }: { data: QRMDashboardMetrics["queueTime" type="category" width={100} tick={{ fill: COLORS.muted, fontSize: 12 }} - axisLine={false} - tickLine={false} - /> - + {data.byCell.map((entry, index) => ( @@ -177,56 +205,49 @@ export const QueueTimeChart = ({ data }: { data: QRMDashboardMetrics["queueTime"
); -}; +}); -// --- Cycle Time Chart (Range Bar simulated with ComposedChart) --- -export const CycleTimeChart = ({ data }: { data: QRMDashboardMetrics["cycleTime"] }) => { +// --- Cycle Time Chart --- +export const CycleTimeChart = memo(({ data }: { data: QRMDashboardMetrics["cycleTime"] }) => { const { t } = useTranslation(); - // Transform data for range visualization - // We'll use a bar for the range (min to max) and a line or scatter for median - // Actually, Recharts doesn't support box plots natively well. - // We can use a stacked bar approach: [transparent (min), bar (max-min)] - // But to show median, we might need a custom shape or composed chart. - - // Simplified approach: Bar chart showing Median, with Error Bars for Min/Max? - // Or just a simple bar chart of Median for now to keep it clean, maybe with a "range" tooltip. - return ( -
+
- + - - + + { - if (name === "median") return [`${value} min`, "Median"]; - return [`${value} min`, name]; + {...TOOLTIP_STYLE} + formatter={(value: number, name: string) => { + if (name === "median") return [`${value} ${t("qrm.cycleTime.minutes")}`, t("qrm.cycleTime.median")]; + return [`${value} ${t("qrm.cycleTime.minutes")}`, name]; }} /> - +
); -}; +}); // --- WIP Age Chart --- -export const WIPAgeChart = ({ data }: { data: QRMDashboardMetrics["wipAge"] }) => { +export const WIPAgeChart = memo(({ data }: { data: QRMDashboardMetrics["wipAge"] }) => { const { t } = useTranslation(); - // Gradient colors for age buckets - const AGE_COLORS = [ - "hsl(var(--color-success))", // 0-2 days - "hsl(var(--color-info))", // 3-5 days - "hsl(var(--color-warning))", // 6-10 days - "hsl(var(--color-error))", // >10 days - ]; - return ( -
+
{t("qrm.wipAge.jobs")} {data.totalWip} @@ -237,6 +258,7 @@ export const WIPAgeChart = ({ data }: { data: QRMDashboardMetrics["wipAge"] }) = data={data.distribution} layout="vertical" margin={{ top: 5, right: 30, left: 40, bottom: 5 }} + accessibilityLayer > @@ -245,14 +267,10 @@ export const WIPAgeChart = ({ data }: { data: QRMDashboardMetrics["wipAge"] }) = type="category" width={50} tick={{ fill: COLORS.muted, fontSize: 12 }} - axisLine={false} - tickLine={false} + {...AXIS_STYLE} /> - - + + {data.distribution.map((entry, index) => ( ))} @@ -262,16 +280,21 @@ export const WIPAgeChart = ({ data }: { data: QRMDashboardMetrics["wipAge"] }) =
); -}; +}); // --- Issue Rate Chart --- -export const IssueRateChart = ({ data }: { data: QRMDashboardMetrics["issueRate"] }) => { +export const IssueRateChart = memo(({ data }: { data: QRMDashboardMetrics["issueRate"] }) => { const { t } = useTranslation(); return ( -
+
- + - +
); -}; +}); // --- Reliability Heatmap --- -export const ReliabilityHeatmap = ({ data }: { data: QRMDashboardMetrics["reliability"] }) => { +export const ReliabilityHeatmap = memo(({ data }: { data: QRMDashboardMetrics["reliability"] }) => { const { t } = useTranslation(); // Helper to get color based on reliability score const getCellColor = (value: number) => { - if (value >= 95) return "hsl(var(--color-success))"; - if (value >= 90) return "hsl(var(--color-info))"; - if (value >= 80) return "hsl(var(--color-warning))"; - return "hsl(var(--color-error))"; + if (value >= 95) return COLORS.success; + if (value >= 90) return COLORS.info; + if (value >= 80) return COLORS.warning; + return COLORS.error; }; return ( -
+
- + {data.periodLabels.map((label, i) => ( - + ))} {data.heatmap.map((row, i) => ( - + {row.values.map((val, j) => (
{t("qrm.reliability.cell")}{t("qrm.reliability.cell")}{label}{label}
{row.cellName}{row.cellName}
{val}
@@ -337,23 +358,23 @@ export const ReliabilityHeatmap = ({ data }: { data: QRMDashboardMetrics["reliab
); -}; +}); // --- Throughput Chart --- -export const ThroughputChart = ({ data }: { data: QRMDashboardMetrics["throughput"] }) => { +export const ThroughputChart = memo(({ data }: { data: QRMDashboardMetrics["throughput"] }) => { const { t } = useTranslation(); return ( -
+
{data.byCell.map((cell, index) => ( -
+
{cell.cellName}
-
+ -
+
{cell.current} u/d
@@ -382,4 +404,14 @@ export const ThroughputChart = ({ data }: { data: QRMDashboardMetrics["throughpu
); -}; +}); + +// Add display names for debugging +MCTChart.displayName = "MCTChart"; +OTPGauge.displayName = "OTPGauge"; +QueueTimeChart.displayName = "QueueTimeChart"; +CycleTimeChart.displayName = "CycleTimeChart"; +WIPAgeChart.displayName = "WIPAgeChart"; +IssueRateChart.displayName = "IssueRateChart"; +ReliabilityHeatmap.displayName = "ReliabilityHeatmap"; +ThroughputChart.displayName = "ThroughputChart"; diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 7912afb8..e6858630 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -151,6 +151,13 @@ "mcpServer": "MCP-Server", "mqttPublishers": "MQTT-Publisher", "shipping": "Versand", + "dataImport": "Datenimport", + "analytics": "Analysen", + "qrmDashboard": "QRM-Dashboard", + "jobsAnalytics": "Auftragsanalysen", + "qualityAnalytics": "Qualitätsanalysen", + "oeeAnalytics": "OEE-Analysen", + "reliabilityAnalytics": "Zuverlässigkeitsanalysen", "sections": { "overview": "ÜBERSICHT", "operations": "ARBEITSGÄNGE", @@ -1714,7 +1721,22 @@ "totalScrapped": "Gesamt verschrottet", "noScrapData": "Noch keine Ausschussdaten erfasst", "usage": "Verwendung", - "scrapped": "Verschrottet" + "scrapped": "Verschrottet", + "issuesByStatus": "Probleme nach Status", + "issuesBySeverity": "Probleme nach Schweregrad", + "pending": "Ausstehend", + "inProgress": "In Bearbeitung", + "total": "Gesamt", + "high": "Hoch", + "medium": "Mittel", + "low": "Niedrig" + }, + "analytics": { + "jobsTitle": "Auftragsanalysen", + "jobsSubtitle": "Verfolgen Sie Auftragsstatus, Fristen und Qualitätsmetriken", + "qualityTitle": "Qualitätsanalysen", + "qualitySubtitle": "Überwachen Sie Ausbeute, Ausschuss und Problemmetriken", + "noQualityData": "Noch keine Qualitätsdaten verfügbar. Beginnen Sie mit der Produktionserfassung, um Metriken zu sehen." }, "sessionTracking": { "currentlyTracking": "Aktuelle Zeiterfassung", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 98534512..1d500642 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -137,6 +137,12 @@ "mcpKeys": "MCP Keys", "mcpServer": "MCP Server", "mqttPublishers": "MQTT Publishers", + "analytics": "Analytics", + "qrmDashboard": "QRM Dashboard", + "jobsAnalytics": "Jobs Analytics", + "qualityAnalytics": "Quality Analytics", + "oeeAnalytics": "OEE Analytics", + "reliabilityAnalytics": "Reliability Analytics", "sections": { "overview": "OVERVIEW", "operations": "OPERATIONS", @@ -1957,7 +1963,22 @@ "totalScrapped": "Total Scrapped", "noScrapData": "No scrap data recorded yet", "usage": "Usage", - "scrapped": "Scrapped" + "scrapped": "Scrapped", + "issuesByStatus": "Issues by Status", + "issuesBySeverity": "Issues by Severity", + "pending": "Pending", + "inProgress": "In Progress", + "total": "Total", + "high": "High", + "medium": "Medium", + "low": "Low" + }, + "analytics": { + "jobsTitle": "Jobs Analytics", + "jobsSubtitle": "Track job status, deadlines, and quality metrics", + "qualityTitle": "Quality Analytics", + "qualitySubtitle": "Monitor yield rates, scrap, and issue metrics", + "noQualityData": "No quality data available yet. Start recording production to see metrics." }, "capacity": { "title": "Capacity Matrix", diff --git a/src/i18n/locales/nl/translation.json b/src/i18n/locales/nl/translation.json index 3e06271e..2dd47598 100644 --- a/src/i18n/locales/nl/translation.json +++ b/src/i18n/locales/nl/translation.json @@ -391,6 +391,13 @@ "mcpServer": "MCP-server", "mqttPublishers": "MQTT Publishers", "shipping": "Verzending", + "dataImport": "Gegevensimport", + "analytics": "Analyses", + "qrmDashboard": "QRM Dashboard", + "jobsAnalytics": "Orderanalyses", + "qualityAnalytics": "Kwaliteitsanalyses", + "oeeAnalytics": "OEE Analyses", + "reliabilityAnalytics": "Betrouwbaarheidsanalyses", "sections": { "overview": "OVERZICHT", "operations": "BEWERKINGEN", @@ -1984,7 +1991,22 @@ "totalScrapped": "Totaal afgekeurd", "noScrapData": "Nog geen afvalgegevens geregistreerd", "usage": "Gebruik", - "scrapped": "Afgekeurd" + "scrapped": "Afgekeurd", + "issuesByStatus": "Problemen per status", + "issuesBySeverity": "Problemen per ernst", + "pending": "In afwachting", + "inProgress": "Bezig", + "total": "Totaal", + "high": "Hoog", + "medium": "Gemiddeld", + "low": "Laag" + }, + "analytics": { + "jobsTitle": "Orderanalyses", + "jobsSubtitle": "Volg orderstatus, deadlines en kwaliteitsmetrieken", + "qualityTitle": "Kwaliteitsanalyses", + "qualitySubtitle": "Monitor opbrengsten, afval en probleemmetrieken", + "noQualityData": "Nog geen kwaliteitsgegevens beschikbaar. Begin met productieregistratie om metrieken te zien." }, "sessionTracking": { "currentlyTracking": "Momenteel aan het bijhouden", diff --git a/src/pages/admin/Jobs.tsx b/src/pages/admin/Jobs.tsx index 9436a613..0dc628f5 100644 --- a/src/pages/admin/Jobs.tsx +++ b/src/pages/admin/Jobs.tsx @@ -5,29 +5,20 @@ import { ColumnDef } from "@tanstack/react-table"; import { supabase } from "@/integrations/supabase/client"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useNavigate } from "react-router-dom"; import { Calendar, Plus, Clock, - AlertCircle, Box, FileText, Eye, MoreHorizontal, - Briefcase, Package, CheckCircle2, PauseCircle, Layers, - AlertTriangle, - TrendingUp, - Trash2, - AlertOctagon, - Activity, } from "lucide-react"; -import { useQualityMetrics } from "@/hooks/useQualityMetrics"; import { format, isBefore, addDays, isAfter } from "date-fns"; import JobDetailModal from "@/components/admin/JobDetailModal"; import DueDateOverrideModal from "@/components/admin/DueDateOverrideModal"; @@ -70,15 +61,6 @@ interface JobData { hasPDF: boolean; } -interface JobStats { - total: number; - inProgress: number; - completed: number; - onHold: number; - overdue: number; - dueThisWeek: number; -} - export default function Jobs() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -136,38 +118,12 @@ export default function Jobs() { }, }); - // Calculate stats from jobs data - const stats: JobStats = useMemo(() => { - if (!jobs) return { total: 0, inProgress: 0, completed: 0, onHold: 0, overdue: 0, dueThisWeek: 0 }; - - const today = new Date(); - const weekFromNow = addDays(today, 7); - - return { - total: jobs.length, - inProgress: jobs.filter((j: any) => j.status === "in_progress").length, - completed: jobs.filter((j: any) => j.status === "completed").length, - onHold: jobs.filter((j: any) => j.status === "on_hold").length, - overdue: jobs.filter((j: any) => { - const dueDate = new Date(j.due_date_override || j.due_date); - return isBefore(dueDate, today) && j.status !== "completed"; - }).length, - dueThisWeek: jobs.filter((j: any) => { - const dueDate = new Date(j.due_date_override || j.due_date); - return isAfter(dueDate, today) && isBefore(dueDate, weekFromNow) && j.status !== "completed"; - }).length, - }; - }, [jobs]); - // Get job IDs for routing fetch const jobIds = useMemo(() => jobs?.map((job: any) => job.id) || [], [jobs]); // Fetch routing for all jobs const { routings, loading: routingsLoading } = useMultipleJobsRouting(jobIds); - // Fetch quality metrics - const { data: qualityMetrics, isLoading: qualityLoading } = useQualityMetrics(); - const handleSetOnHold = async (jobId: string) => { await supabase.from("jobs").update({ status: "on_hold" }).eq("id", jobId); refetch(); @@ -513,220 +469,6 @@ export default function Jobs() {
- {/* Stats Cards */} -
- - -
-
- -
-
-
{stats.total}
-
{t("jobs.total", "Total")}
-
-
-
-
- - - -
-
- -
-
-
{stats.inProgress}
-
{t("operations.status.inProgress")}
-
-
-
-
- - - -
-
- -
-
-
{stats.completed}
-
{t("operations.status.completed")}
-
-
-
-
- - - -
-
- -
-
-
{stats.onHold}
-
{t("operations.status.onHold")}
-
-
-
-
- - 0 && "border-[hsl(var(--color-error))]/30" - )}> - -
-
- -
-
-
0 && "text-[hsl(var(--color-error))]" - )}>{stats.overdue}
-
{t("jobs.overdue", "Overdue")}
-
-
-
-
- - 0 && "border-[hsl(var(--color-warning))]/30" - )}> - -
-
- -
-
-
0 && "text-[hsl(var(--color-warning))]" - )}>{stats.dueThisWeek}
-
{t("jobs.dueThisWeek", "Due This Week")}
-
-
-
-
-
- - {/* Quality Metrics Dashboard */} - {qualityMetrics && (qualityMetrics.totalProduced > 0 || qualityMetrics.issueMetrics.total > 0) && ( - - - - - {t("quality.dashboardTitle", "Quality Overview")} - - - -
- {/* Yield Rate */} -
-
- - {t("quality.yieldRate", "Yield Rate")} -
-
= 95 ? "text-[hsl(var(--color-success))]" : - qualityMetrics.overallYield >= 85 ? "text-[hsl(var(--color-warning))]" : - "text-[hsl(var(--color-error))]" - )}> - {qualityMetrics.overallYield.toFixed(1)}% -
-
- - {/* Total Produced */} -
-
- - {t("quality.totalProduced", "Produced")} -
-
{qualityMetrics.totalProduced.toLocaleString()}
-
- - {/* Good Parts */} -
-
- - {t("quality.goodParts", "Good")} -
-
- {qualityMetrics.totalGood.toLocaleString()} -
-
- - {/* Scrap */} -
-
- - {t("quality.scrap", "Scrap")} -
-
0 ? "text-[hsl(var(--color-error))]" : "" - )}> - {qualityMetrics.totalScrap.toLocaleString()} - {qualityMetrics.scrapRate > 0 && ( - ({qualityMetrics.scrapRate.toFixed(1)}%) - )} -
-
- - {/* Open Issues */} -
-
- - {t("quality.openIssues", "Open Issues")} -
-
0 ? "text-[hsl(var(--color-warning))]" : "" - )}> - {qualityMetrics.issueMetrics.pending} - - / {qualityMetrics.issueMetrics.total} - -
-
- - {/* Critical Issues */} -
-
- - {t("quality.critical", "Critical")} -
-
0 ? "text-[hsl(var(--color-error))]" : "" - )}> - {qualityMetrics.issueMetrics.bySeverity.critical} -
-
-
- - {/* Top Scrap Reasons Mini-Bar */} - {qualityMetrics.topScrapReasons.length > 0 && ( -
-
- {t("quality.topScrapReasons", "Top Scrap Reasons")} -
-
- {qualityMetrics.topScrapReasons.slice(0, 5).map((reason) => ( - - {reason.code}: {reason.quantity} - - ))} -
-
- )} -
-
- )} - {/* Jobs Table */}
- {/* Quality Metrics Dashboard */} - {qualityMetrics && (qualityMetrics.totalProduced > 0 || qualityMetrics.issueMetrics.total > 0) && ( - - - - - {t("quality.partsQuality", "Parts Quality Overview")} - - - -
- {/* Yield Rate */} -
-
- - {t("quality.yieldRate", "Yield Rate")} -
-
= 95 ? "text-[hsl(var(--color-success))]" : - qualityMetrics.overallYield >= 85 ? "text-[hsl(var(--color-warning))]" : - "text-[hsl(var(--color-error))]" - )}> - {qualityMetrics.overallYield.toFixed(1)}% -
-
- - {/* Good Parts */} -
-
- - {t("quality.goodParts", "Good")} -
-
- {qualityMetrics.totalGood.toLocaleString()} -
-
- - {/* Scrap */} -
-
- - {t("quality.scrap", "Scrap")} -
-
0 ? "text-[hsl(var(--color-error))]" : "" - )}> - {qualityMetrics.totalScrap.toLocaleString()} - {qualityMetrics.scrapRate > 0 && ( - ({qualityMetrics.scrapRate.toFixed(1)}%) - )} -
-
- - {/* Rework */} -
-
- - {t("quality.rework", "Rework")} -
-
0 ? "text-[hsl(var(--color-warning))]" : "" - )}> - {qualityMetrics.totalRework.toLocaleString()} - {qualityMetrics.reworkRate > 0 && ( - ({qualityMetrics.reworkRate.toFixed(1)}%) - )} -
-
- - {/* Issues */} -
-
- - {t("quality.openIssues", "Open Issues")} -
-
0 ? "text-[hsl(var(--color-warning))]" : "" - )}> - {qualityMetrics.issueMetrics.pending} - - / {qualityMetrics.issueMetrics.total} - -
-
-
- - {/* Scrap by Category */} - {qualityMetrics.scrapByCategory.length > 0 && ( -
-
- {t("quality.scrapByCategory", "Scrap by Category")} -
-
- {qualityMetrics.scrapByCategory.map((cat) => ( - - {cat.category}: {cat.quantity} - - ))} -
-
- )} -
-
- )} -
{ + const { data, error } = await supabase + .from("jobs") + .select("id, status, due_date, due_date_override, created_at"); + if (error) throw error; + return data; + }, + }); + + const { data: qualityMetrics, isLoading: qualityLoading } = useQualityMetrics(); + + const stats: JobStats = useMemo(() => { + if (!jobs) return { total: 0, inProgress: 0, completed: 0, onHold: 0, overdue: 0, dueThisWeek: 0, notStarted: 0 }; + + const today = new Date(); + const weekFromNow = addDays(today, 7); + + return { + total: jobs.length, + inProgress: jobs.filter((j) => j.status === "in_progress").length, + completed: jobs.filter((j) => j.status === "completed").length, + onHold: jobs.filter((j) => j.status === "on_hold").length, + notStarted: jobs.filter((j) => j.status === "not_started").length, + overdue: jobs.filter((j) => { + const dueDate = new Date(j.due_date_override || j.due_date); + return isBefore(dueDate, today) && j.status !== "completed"; + }).length, + dueThisWeek: jobs.filter((j) => { + const dueDate = new Date(j.due_date_override || j.due_date); + return isAfter(dueDate, today) && isBefore(dueDate, weekFromNow) && j.status !== "completed"; + }).length, + }; + }, [jobs]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {t("analytics.jobsTitle")} +

+

+ {t("analytics.jobsSubtitle")} +

+
+
+ +
+ + {/* Job Stats Cards */} +
+ + +
+
+ +
+
+
{stats.total}
+
{t("jobs.total")}
+
+
+
+
+ + + +
+
+ +
+
+
{stats.inProgress}
+
{t("operations.status.inProgress")}
+
+
+
+
+ + + +
+
+ +
+
+
{stats.completed}
+
{t("operations.status.completed")}
+
+
+
+
+ + + +
+
+ +
+
+
{stats.onHold}
+
{t("operations.status.onHold")}
+
+
+
+
+ + 0 && "border-[hsl(var(--color-error))]/30" + )}> + +
+
+ +
+
+
0 && "text-[hsl(var(--color-error))]" + )}>{stats.overdue}
+
{t("jobs.overdue")}
+
+
+
+
+ + 0 && "border-[hsl(var(--color-warning))]/30" + )}> + +
+
+ +
+
+
0 && "text-[hsl(var(--color-warning))]" + )}>{stats.dueThisWeek}
+
{t("jobs.dueThisWeek")}
+
+
+
+
+
+ + {/* Quality Metrics Dashboard */} + {qualityMetrics && (qualityMetrics.totalProduced > 0 || qualityMetrics.issueMetrics.total > 0) && ( + + + + + {t("quality.dashboardTitle")} + + + +
+ {/* Yield Rate */} +
+
+ + {t("quality.yieldRate")} +
+
= 95 ? "text-[hsl(var(--color-success))]" : + qualityMetrics.overallYield >= 85 ? "text-[hsl(var(--color-warning))]" : + "text-[hsl(var(--color-error))]" + )}> + {qualityMetrics.overallYield.toFixed(1)}% +
+
+ + {/* Total Produced */} +
+
+ + {t("quality.totalProduced")} +
+
{qualityMetrics.totalProduced.toLocaleString()}
+
+ + {/* Good Parts */} +
+
+ + {t("quality.goodParts")} +
+
+ {qualityMetrics.totalGood.toLocaleString()} +
+
+ + {/* Scrap */} +
+
+ + {t("quality.scrap")} +
+
0 ? "text-[hsl(var(--color-error))]" : "" + )}> + {qualityMetrics.totalScrap.toLocaleString()} + {qualityMetrics.scrapRate > 0 && ( + ({qualityMetrics.scrapRate.toFixed(1)}%) + )} +
+
+ + {/* Open Issues */} +
+
+ + {t("quality.openIssues")} +
+
0 ? "text-[hsl(var(--color-warning))]" : "" + )}> + {qualityMetrics.issueMetrics.pending} + + / {qualityMetrics.issueMetrics.total} + +
+
+ + {/* Critical Issues */} +
+
+ + {t("quality.critical")} +
+
0 ? "text-[hsl(var(--color-error))]" : "" + )}> + {qualityMetrics.issueMetrics.bySeverity.critical} +
+
+
+ + {/* Top Scrap Reasons Mini-Bar */} + {qualityMetrics.topScrapReasons.length > 0 && ( +
+
+ {t("quality.topScrapReasons")} +
+
+ {qualityMetrics.topScrapReasons.slice(0, 5).map((reason) => ( + + {reason.code}: {reason.quantity} + + ))} +
+
+ )} +
+
+ )} + + {/* Link to Jobs page */} +
+ +
+
+ ); +} diff --git a/src/pages/admin/analytics/QualityAnalytics.tsx b/src/pages/admin/analytics/QualityAnalytics.tsx new file mode 100644 index 00000000..f239ad1b --- /dev/null +++ b/src/pages/admin/analytics/QualityAnalytics.tsx @@ -0,0 +1,296 @@ +import { useTranslation } from "react-i18next"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ArrowLeft, TrendingUp, CheckCircle2, Trash2, RefreshCw, AlertTriangle, Activity, Target } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { useQualityMetrics } from "@/hooks/useQualityMetrics"; +import { cn } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; + +export default function QualityAnalytics() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const { data: qualityMetrics, isLoading } = useQualityMetrics(); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ {t("analytics.qualityTitle")} +

+

+ {t("analytics.qualitySubtitle")} +

+
+
+ +
+ + {qualityMetrics && (qualityMetrics.totalProduced > 0 || qualityMetrics.issueMetrics.total > 0) ? ( + <> + {/* Main Quality Metrics */} + + + + + {t("quality.partsQuality")} + + + +
+ {/* Yield Rate */} +
+
+ + {t("quality.yieldRate")} +
+
= 95 ? "text-[hsl(var(--color-success))]" : + qualityMetrics.overallYield >= 85 ? "text-[hsl(var(--color-warning))]" : + "text-[hsl(var(--color-error))]" + )}> + {qualityMetrics.overallYield.toFixed(1)}% +
+
+ + {/* Good Parts */} +
+
+ + {t("quality.goodParts")} +
+
+ {qualityMetrics.totalGood.toLocaleString()} +
+
+ + {/* Scrap */} +
+
+ + {t("quality.scrap")} +
+
0 ? "text-[hsl(var(--color-error))]" : "" + )}> + {qualityMetrics.totalScrap.toLocaleString()} + {qualityMetrics.scrapRate > 0 && ( + ({qualityMetrics.scrapRate.toFixed(1)}%) + )} +
+
+ + {/* Rework */} +
+
+ + {t("quality.rework")} +
+
0 ? "text-[hsl(var(--color-warning))]" : "" + )}> + {qualityMetrics.totalRework.toLocaleString()} + {qualityMetrics.reworkRate > 0 && ( + ({qualityMetrics.reworkRate.toFixed(1)}%) + )} +
+
+ + {/* Issues */} +
+
+ + {t("quality.openIssues")} +
+
0 ? "text-[hsl(var(--color-warning))]" : "" + )}> + {qualityMetrics.issueMetrics.pending} + + / {qualityMetrics.issueMetrics.total} + +
+
+
+
+
+ + {/* Scrap by Category */} + {qualityMetrics.scrapByCategory.length > 0 && ( + + + + + {t("quality.scrapByCategory")} + + + +
+ {qualityMetrics.scrapByCategory.map((cat) => ( + + +
{cat.category}
+
{cat.quantity}
+
+
+ ))} +
+
+
+ )} + + {/* Top Scrap Reasons */} + {qualityMetrics.topScrapReasons.length > 0 && ( + + + + + {t("quality.topScrapReasons")} + + + +
+ {qualityMetrics.topScrapReasons.map((reason, index) => ( +
+
+ {index + 1} +
+
+
+ {reason.code} + + {reason.quantity} units + +
+
+
+
+
+
+ ))} +
+ + + )} + + {/* Issue Metrics */} + + + + + {t("quality.issuesByStatus")} + + + +
+
+
{t("quality.pending")}
+
+ {qualityMetrics.issueMetrics.pending} +
+
+
+
{t("quality.inProgress")}
+
+ {qualityMetrics.issueMetrics.inProgress} +
+
+
+
{t("quality.resolved")}
+
+ {qualityMetrics.issueMetrics.resolved} +
+
+
+
{t("quality.total")}
+
{qualityMetrics.issueMetrics.total}
+
+
+
+
+ + {/* Issues by Severity */} + + + + + {t("quality.issuesBySeverity")} + + + +
+
+
{t("quality.critical")}
+
+ {qualityMetrics.issueMetrics.bySeverity.critical} +
+
+
+
{t("quality.high")}
+
+ {qualityMetrics.issueMetrics.bySeverity.high} +
+
+
+
{t("quality.medium")}
+
+ {qualityMetrics.issueMetrics.bySeverity.medium} +
+
+
+
{t("quality.low")}
+
+ {qualityMetrics.issueMetrics.bySeverity.low} +
+
+
+
+
+ + ) : ( + + + +

{t("analytics.noQualityData")}

+
+
+ )} + + {/* Link to Parts page */} +
+ +
+
+ ); +} diff --git a/src/routes.ts b/src/routes.ts index 714a0c92..4e36a87e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -44,6 +44,8 @@ export const ROUTES = { RELIABILITY: "/admin/analytics/reliability", QRM: "/admin/analytics/qrm", QRM_DASHBOARD: "/admin/analytics/qrm-dashboard", + JOBS: "/admin/analytics/jobs", + QUALITY: "/admin/analytics/quality", } },