mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
refactor: Extract WorkflowStepper sub-components and utilities
- Move WorkflowStepper into dedicated folder with sub-components - Extract StepCircle, StepLabel, and StepPopover components - Create workflowSteps constant for step definitions - Extract getCurrentStep logic to workflowStepCalculation utility - Extract getGuideContent logic to workflowGuideContent utility - Reduce WorkflowStepper.tsx from 487 to 140 lines (71% reduction) Part of #33: Extract sub-components from large components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec19426dd1
commit
2de8cd12ff
9 changed files with 623 additions and 487 deletions
|
|
@ -1,487 +0,0 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { useClickOutside } from "@/hooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
|
||||
import { usePatternStore } from "../stores/usePatternStore";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MachineStatus } from "../types/machine";
|
||||
|
||||
interface Step {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 1, label: "Connect", description: "Connect to machine" },
|
||||
{ id: 2, label: "Home Machine", description: "Initialize hoop position" },
|
||||
{ id: 3, label: "Load Pattern", description: "Choose PES file" },
|
||||
{ id: 4, label: "Upload", description: "Upload to machine" },
|
||||
{ id: 5, label: "Mask Trace", description: "Trace pattern area" },
|
||||
{ id: 6, label: "Start Sewing", description: "Begin embroidery" },
|
||||
{ id: 7, label: "Monitor", description: "Watch progress" },
|
||||
{ id: 8, label: "Complete", description: "Finish and remove" },
|
||||
];
|
||||
|
||||
// Helper function to get guide content for a step
|
||||
function getGuideContent(stepId: number, machineStatus: MachineStatus) {
|
||||
// Return content based on step
|
||||
switch (stepId) {
|
||||
case 1:
|
||||
return {
|
||||
type: "info" as const,
|
||||
title: "Step 1: Connect to Machine",
|
||||
description:
|
||||
"To get started, connect to your Brother embroidery machine via Bluetooth.",
|
||||
items: [
|
||||
"Make sure your machine is powered on",
|
||||
"Enable Bluetooth on your machine",
|
||||
'Click the "Connect to Machine" button below',
|
||||
],
|
||||
};
|
||||
|
||||
case 2:
|
||||
return {
|
||||
type: "info" as const,
|
||||
title: "Step 2: Home Machine",
|
||||
description:
|
||||
"The hoop needs to be removed and an initial homing procedure must be performed.",
|
||||
items: [
|
||||
"Remove the embroidery hoop from the machine completely",
|
||||
"Press the Accept button on the machine",
|
||||
"Wait for the machine to complete its initialization (homing)",
|
||||
"Once initialization is complete, reattach the hoop",
|
||||
"The machine should now recognize the hoop correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 3:
|
||||
return {
|
||||
type: "info" as const,
|
||||
title: "Step 3: Load Your Pattern",
|
||||
description:
|
||||
"Choose a PES embroidery file from your computer to preview and upload.",
|
||||
items: [
|
||||
'Click "Choose PES File" in the Pattern File section',
|
||||
"Select your embroidery design (.pes file)",
|
||||
"Review the pattern preview on the right",
|
||||
"You can drag the pattern to adjust its position",
|
||||
],
|
||||
};
|
||||
|
||||
case 4:
|
||||
return {
|
||||
type: "info" as const,
|
||||
title: "Step 4: Upload Pattern to Machine",
|
||||
description:
|
||||
"Send your pattern to the embroidery machine to prepare for sewing.",
|
||||
items: [
|
||||
"Review the pattern preview to ensure it's positioned correctly",
|
||||
"Check the pattern size matches your hoop",
|
||||
'Click "Upload to Machine" when ready',
|
||||
"Wait for the upload to complete (this may take a minute)",
|
||||
],
|
||||
};
|
||||
|
||||
case 5:
|
||||
// Check machine status for substates
|
||||
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
|
||||
return {
|
||||
type: "warning" as const,
|
||||
title: "Machine Action Required",
|
||||
description: "The machine is ready to trace the pattern outline.",
|
||||
items: [
|
||||
"Press the button on your machine to confirm and start the mask trace",
|
||||
"Ensure the hoop is properly attached",
|
||||
"Make sure the needle area is clear",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (machineStatus === MachineStatus.MASK_TRACING) {
|
||||
return {
|
||||
type: "progress" as const,
|
||||
title: "Mask Trace In Progress",
|
||||
description:
|
||||
"The machine is tracing the pattern boundary. Please wait...",
|
||||
items: [
|
||||
"Watch the machine trace the outline",
|
||||
"Verify the pattern fits within your hoop",
|
||||
"Do not interrupt the machine",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "info" as const,
|
||||
title: "Step 5: Start Mask Trace",
|
||||
description:
|
||||
"The mask trace helps the machine understand the pattern boundaries.",
|
||||
items: [
|
||||
'Click "Start Mask Trace" button in the Sewing Progress section',
|
||||
"The machine will trace the pattern outline",
|
||||
"This ensures the hoop is positioned correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 6:
|
||||
return {
|
||||
type: "success" as const,
|
||||
title: "Step 6: Ready to Sew!",
|
||||
description: "The machine is ready to begin embroidering your pattern.",
|
||||
items: [
|
||||
"Verify your thread colors are correct",
|
||||
"Ensure the fabric is properly hooped",
|
||||
'Click "Start Sewing" when ready',
|
||||
],
|
||||
};
|
||||
|
||||
case 7:
|
||||
// Check for substates
|
||||
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
|
||||
return {
|
||||
type: "warning" as const,
|
||||
title: "Thread Change Required",
|
||||
description:
|
||||
"The machine needs a different thread color to continue.",
|
||||
items: [
|
||||
"Check the color blocks section to see which thread is needed",
|
||||
"Change to the correct thread color",
|
||||
"Press the button on your machine to resume sewing",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
machineStatus === MachineStatus.PAUSE ||
|
||||
machineStatus === MachineStatus.STOP ||
|
||||
machineStatus === MachineStatus.SEWING_INTERRUPTION
|
||||
) {
|
||||
return {
|
||||
type: "warning" as const,
|
||||
title: "Sewing Paused",
|
||||
description: "The embroidery has been paused or interrupted.",
|
||||
items: [
|
||||
"Check if everything is okay with the machine",
|
||||
'Click "Resume Sewing" when ready to continue',
|
||||
"The machine will pick up where it left off",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "progress" as const,
|
||||
title: "Step 7: Sewing In Progress",
|
||||
description:
|
||||
"Your embroidery is being stitched. Monitor the progress below.",
|
||||
items: [
|
||||
"Watch the progress bar and current stitch count",
|
||||
"The machine will pause when a color change is needed",
|
||||
"Do not leave the machine unattended",
|
||||
],
|
||||
};
|
||||
|
||||
case 8:
|
||||
return {
|
||||
type: "success" as const,
|
||||
title: "Step 8: Embroidery Complete!",
|
||||
description: "Your embroidery is finished. Great work!",
|
||||
items: [
|
||||
"Remove the hoop from the machine",
|
||||
"Press the Accept button on the machine",
|
||||
"Carefully remove your finished embroidery",
|
||||
"Trim any jump stitches or loose threads",
|
||||
'Click "Delete Pattern" to start a new project',
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentStep(
|
||||
machineStatus: MachineStatus,
|
||||
isConnected: boolean,
|
||||
hasPattern: boolean,
|
||||
patternUploaded: boolean,
|
||||
): number {
|
||||
if (!isConnected) return 1;
|
||||
|
||||
// Check if machine needs homing (Initial state)
|
||||
if (machineStatus === MachineStatus.Initial) return 2;
|
||||
|
||||
if (!hasPattern) return 3;
|
||||
if (!patternUploaded) return 4;
|
||||
|
||||
// After upload, determine step based on machine status
|
||||
switch (machineStatus) {
|
||||
case MachineStatus.IDLE:
|
||||
case MachineStatus.MASK_TRACE_LOCK_WAIT:
|
||||
case MachineStatus.MASK_TRACING:
|
||||
return 5;
|
||||
|
||||
case MachineStatus.MASK_TRACE_COMPLETE:
|
||||
case MachineStatus.SEWING_WAIT:
|
||||
return 6;
|
||||
|
||||
case MachineStatus.SEWING:
|
||||
case MachineStatus.COLOR_CHANGE_WAIT:
|
||||
case MachineStatus.PAUSE:
|
||||
case MachineStatus.STOP:
|
||||
case MachineStatus.SEWING_INTERRUPTION:
|
||||
return 7;
|
||||
|
||||
case MachineStatus.SEWING_COMPLETE:
|
||||
return 8;
|
||||
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkflowStepper() {
|
||||
// Machine store
|
||||
const { machineStatus, isConnected } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
machineStatus: state.machineStatus,
|
||||
isConnected: state.isConnected,
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
const { pesData } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const hasPattern = pesData !== null;
|
||||
const currentStep = getCurrentStep(
|
||||
machineStatus,
|
||||
isConnected,
|
||||
hasPattern,
|
||||
patternUploaded,
|
||||
);
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [popoverStep, setPopoverStep] = useState<number | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
||||
|
||||
// Close popover when clicking outside (exclude step circles)
|
||||
useClickOutside<HTMLDivElement>(popoverRef, () => setShowPopover(false), {
|
||||
enabled: showPopover,
|
||||
excludeRefs: [stepRefs],
|
||||
});
|
||||
|
||||
const handleStepClick = (stepId: number) => {
|
||||
// Only allow clicking on current step or earlier completed steps
|
||||
if (stepId <= currentStep) {
|
||||
if (showPopover && popoverStep === stepId) {
|
||||
setShowPopover(false);
|
||||
setPopoverStep(null);
|
||||
} else {
|
||||
setPopoverStep(stepId);
|
||||
setShowPopover(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
|
||||
role="navigation"
|
||||
aria-label="Workflow progress"
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<div
|
||||
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
|
||||
style={{ left: "16px", right: "16px" }}
|
||||
/>
|
||||
|
||||
{/* Progress bar fill */}
|
||||
<div
|
||||
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
|
||||
style={{
|
||||
left: "16px",
|
||||
width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
|
||||
}}
|
||||
role="progressbar"
|
||||
aria-valuenow={currentStep}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={steps.length}
|
||||
aria-label={`Step ${currentStep} of ${steps.length}`}
|
||||
/>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex justify-between relative">
|
||||
{steps.map((step) => {
|
||||
const isComplete = step.id < currentStep;
|
||||
const isCurrent = step.id === currentStep;
|
||||
const isUpcoming = step.id > currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex flex-col items-center"
|
||||
style={{ flex: 1 }}
|
||||
role="listitem"
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
>
|
||||
{/* Step circle */}
|
||||
<div
|
||||
ref={(el) => {
|
||||
stepRefs.current[step.id] = el;
|
||||
}}
|
||||
onClick={() => handleStepClick(step.id)}
|
||||
className={`
|
||||
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
|
||||
${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
|
||||
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
|
||||
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
|
||||
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
|
||||
${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""}
|
||||
`}
|
||||
aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
|
||||
role="button"
|
||||
tabIndex={step.id <= currentStep ? 0 : -1}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircleIcon
|
||||
className="w-5 h-5 lg:w-6 lg:h-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step label */}
|
||||
<div className="mt-1 lg:mt-2 text-center">
|
||||
<div
|
||||
className={`text-xs font-semibold leading-tight ${
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: isComplete
|
||||
? "text-success-200 dark:text-success-300"
|
||||
: "text-primary-300/70 dark:text-primary-400/70"
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Popover */}
|
||||
{showPopover && popoverStep !== null && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-label="Step guidance"
|
||||
>
|
||||
{(() => {
|
||||
const content = getGuideContent(popoverStep, machineStatus);
|
||||
if (!content) return null;
|
||||
|
||||
const colorClasses = {
|
||||
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
||||
success:
|
||||
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
|
||||
warning:
|
||||
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
|
||||
error:
|
||||
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
|
||||
progress:
|
||||
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
||||
};
|
||||
|
||||
const iconColorClasses = {
|
||||
info: "text-info-600 dark:text-info-400",
|
||||
success: "text-success-600 dark:text-success-400",
|
||||
warning: "text-warning-600 dark:text-warning-400",
|
||||
error: "text-danger-600 dark:text-danger-400",
|
||||
progress: "text-info-600 dark:text-info-400",
|
||||
};
|
||||
|
||||
const textColorClasses = {
|
||||
info: "text-info-900 dark:text-info-200",
|
||||
success: "text-success-900 dark:text-success-200",
|
||||
warning: "text-warning-900 dark:text-warning-200",
|
||||
error: "text-danger-900 dark:text-danger-200",
|
||||
progress: "text-info-900 dark:text-info-200",
|
||||
};
|
||||
|
||||
const descColorClasses = {
|
||||
info: "text-info-800 dark:text-info-300",
|
||||
success: "text-success-800 dark:text-success-300",
|
||||
warning: "text-warning-800 dark:text-warning-300",
|
||||
error: "text-danger-800 dark:text-danger-300",
|
||||
progress: "text-info-800 dark:text-info-300",
|
||||
};
|
||||
|
||||
const listColorClasses = {
|
||||
info: "text-blue-700 dark:text-blue-300",
|
||||
success: "text-green-700 dark:text-green-300",
|
||||
warning: "text-yellow-700 dark:text-yellow-300",
|
||||
error: "text-red-700 dark:text-red-300",
|
||||
progress: "text-cyan-700 dark:text-cyan-300",
|
||||
};
|
||||
|
||||
const Icon =
|
||||
content.type === "warning"
|
||||
? ExclamationTriangleIcon
|
||||
: InformationCircleIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
|
||||
>
|
||||
{content.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`text-sm ${descColorClasses[content.type]} mb-3`}
|
||||
>
|
||||
{content.description}
|
||||
</p>
|
||||
{content.items && content.items.length > 0 && (
|
||||
<ul
|
||||
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
|
||||
>
|
||||
{content.items.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="pl-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.replace(
|
||||
/\*\*(.*?)\*\*/g,
|
||||
"<strong>$1</strong>",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/WorkflowStepper/StepCircle.tsx
Normal file
54
src/components/WorkflowStepper/StepCircle.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* StepCircle Component
|
||||
*
|
||||
* Renders a circular step indicator with number or checkmark icon
|
||||
*/
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
export interface StepCircleProps {
|
||||
stepId: number;
|
||||
label: string;
|
||||
isComplete: boolean;
|
||||
isCurrent: boolean;
|
||||
isUpcoming: boolean;
|
||||
showPopover: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const StepCircle = forwardRef<HTMLDivElement, StepCircleProps>(
|
||||
(
|
||||
{ stepId, label, isComplete, isCurrent, isUpcoming, showPopover, onClick },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
|
||||
${stepId <= (isCurrent ? stepId : isComplete ? stepId : stepId - 1) ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
|
||||
${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
|
||||
${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
|
||||
${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
|
||||
${showPopover ? "ring-4 ring-white dark:ring-gray-800" : ""}
|
||||
`}
|
||||
aria-label={`${label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
|
||||
role="button"
|
||||
tabIndex={isComplete || isCurrent ? 0 : -1}
|
||||
>
|
||||
{isComplete ? (
|
||||
<CheckCircleIcon
|
||||
className="w-5 h-5 lg:w-6 lg:h-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
stepId
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
StepCircle.displayName = "StepCircle";
|
||||
29
src/components/WorkflowStepper/StepLabel.tsx
Normal file
29
src/components/WorkflowStepper/StepLabel.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* StepLabel Component
|
||||
*
|
||||
* Renders the text label below each step circle
|
||||
*/
|
||||
|
||||
export interface StepLabelProps {
|
||||
label: string;
|
||||
isCurrent: boolean;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export function StepLabel({ label, isCurrent, isComplete }: StepLabelProps) {
|
||||
return (
|
||||
<div className="mt-1 lg:mt-2 text-center">
|
||||
<div
|
||||
className={`text-xs font-semibold leading-tight ${
|
||||
isCurrent
|
||||
? "text-white"
|
||||
: isComplete
|
||||
? "text-success-200 dark:text-success-300"
|
||||
: "text-primary-300/70 dark:text-primary-400/70"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
src/components/WorkflowStepper/StepPopover.tsx
Normal file
123
src/components/WorkflowStepper/StepPopover.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* StepPopover Component
|
||||
*
|
||||
* Renders the guidance popover with dynamic content based on step and machine status
|
||||
*/
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MachineStatus } from "../../types/machine";
|
||||
import { getGuideContent } from "../../utils/workflowGuideContent";
|
||||
|
||||
export interface StepPopoverProps {
|
||||
stepId: number;
|
||||
machineStatus: MachineStatus;
|
||||
}
|
||||
|
||||
export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
|
||||
({ stepId, machineStatus }, ref) => {
|
||||
const content = getGuideContent(stepId, machineStatus);
|
||||
if (!content) return null;
|
||||
|
||||
const colorClasses = {
|
||||
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
||||
success:
|
||||
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
|
||||
warning:
|
||||
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
|
||||
error:
|
||||
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
|
||||
progress:
|
||||
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
|
||||
};
|
||||
|
||||
const iconColorClasses = {
|
||||
info: "text-info-600 dark:text-info-400",
|
||||
success: "text-success-600 dark:text-success-400",
|
||||
warning: "text-warning-600 dark:text-warning-400",
|
||||
error: "text-danger-600 dark:text-danger-400",
|
||||
progress: "text-info-600 dark:text-info-400",
|
||||
};
|
||||
|
||||
const textColorClasses = {
|
||||
info: "text-info-900 dark:text-info-200",
|
||||
success: "text-success-900 dark:text-success-200",
|
||||
warning: "text-warning-900 dark:text-warning-200",
|
||||
error: "text-danger-900 dark:text-danger-200",
|
||||
progress: "text-info-900 dark:text-info-200",
|
||||
};
|
||||
|
||||
const descColorClasses = {
|
||||
info: "text-info-800 dark:text-info-300",
|
||||
success: "text-success-800 dark:text-success-300",
|
||||
warning: "text-warning-800 dark:text-warning-300",
|
||||
error: "text-danger-800 dark:text-danger-300",
|
||||
progress: "text-info-800 dark:text-info-300",
|
||||
};
|
||||
|
||||
const listColorClasses = {
|
||||
info: "text-blue-700 dark:text-blue-300",
|
||||
success: "text-green-700 dark:text-green-300",
|
||||
warning: "text-yellow-700 dark:text-yellow-300",
|
||||
error: "text-red-700 dark:text-red-300",
|
||||
progress: "text-cyan-700 dark:text-cyan-300",
|
||||
};
|
||||
|
||||
const Icon =
|
||||
content.type === "warning"
|
||||
? ExclamationTriangleIcon
|
||||
: InformationCircleIcon;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full mt-4 left-1/2 transform -translate-x-1/2 w-full max-w-xl z-50 animate-fadeIn"
|
||||
role="dialog"
|
||||
aria-label="Step guidance"
|
||||
>
|
||||
<div
|
||||
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon
|
||||
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
|
||||
>
|
||||
{content.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
|
||||
{content.description}
|
||||
</p>
|
||||
{content.items && content.items.length > 0 && (
|
||||
<ul
|
||||
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
|
||||
>
|
||||
{content.items.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="pl-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.replace(
|
||||
/\*\*(.*?)\*\*/g,
|
||||
"<strong>$1</strong>",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
StepPopover.displayName = "StepPopover";
|
||||
141
src/components/WorkflowStepper/WorkflowStepper.tsx
Normal file
141
src/components/WorkflowStepper/WorkflowStepper.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/**
|
||||
* WorkflowStepper Component
|
||||
*
|
||||
* Displays the 8-step embroidery workflow with progress tracking and contextual guidance
|
||||
*/
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useClickOutside } from "@/hooks";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMachineStore, usePatternUploaded } from "../../stores/useMachineStore";
|
||||
import { usePatternStore } from "../../stores/usePatternStore";
|
||||
import { WORKFLOW_STEPS } from "../../constants/workflowSteps";
|
||||
import { getCurrentStep } from "../../utils/workflowStepCalculation";
|
||||
import { StepCircle } from "./StepCircle";
|
||||
import { StepLabel } from "./StepLabel";
|
||||
import { StepPopover } from "./StepPopover";
|
||||
|
||||
export function WorkflowStepper() {
|
||||
// Machine store
|
||||
const { machineStatus, isConnected } = useMachineStore(
|
||||
useShallow((state) => ({
|
||||
machineStatus: state.machineStatus,
|
||||
isConnected: state.isConnected,
|
||||
})),
|
||||
);
|
||||
|
||||
// Pattern store
|
||||
const { pesData } = usePatternStore(
|
||||
useShallow((state) => ({
|
||||
pesData: state.pesData,
|
||||
})),
|
||||
);
|
||||
|
||||
// Derived state: pattern is uploaded if machine has pattern info
|
||||
const patternUploaded = usePatternUploaded();
|
||||
const hasPattern = pesData !== null;
|
||||
const currentStep = getCurrentStep(
|
||||
machineStatus,
|
||||
isConnected,
|
||||
hasPattern,
|
||||
patternUploaded,
|
||||
);
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const [popoverStep, setPopoverStep] = useState<number | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const stepRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
||||
|
||||
// Close popover when clicking outside (exclude step circles)
|
||||
useClickOutside<HTMLDivElement>(popoverRef, () => setShowPopover(false), {
|
||||
enabled: showPopover,
|
||||
excludeRefs: [stepRefs],
|
||||
});
|
||||
|
||||
const handleStepClick = (stepId: number) => {
|
||||
// Only allow clicking on current step or earlier completed steps
|
||||
if (stepId <= currentStep) {
|
||||
if (showPopover && popoverStep === stepId) {
|
||||
setShowPopover(false);
|
||||
setPopoverStep(null);
|
||||
} else {
|
||||
setPopoverStep(stepId);
|
||||
setShowPopover(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
|
||||
role="navigation"
|
||||
aria-label="Workflow progress"
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<div
|
||||
className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
|
||||
style={{ left: "16px", right: "16px" }}
|
||||
/>
|
||||
|
||||
{/* Progress bar fill */}
|
||||
<div
|
||||
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
|
||||
style={{
|
||||
left: "16px",
|
||||
width: `calc(${((currentStep - 1) / (WORKFLOW_STEPS.length - 1)) * 100}% - 16px)`,
|
||||
}}
|
||||
role="progressbar"
|
||||
aria-valuenow={currentStep}
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={WORKFLOW_STEPS.length}
|
||||
aria-label={`Step ${currentStep} of ${WORKFLOW_STEPS.length}`}
|
||||
/>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex justify-between relative">
|
||||
{WORKFLOW_STEPS.map((step) => {
|
||||
const isComplete = step.id < currentStep;
|
||||
const isCurrent = step.id === currentStep;
|
||||
const isUpcoming = step.id > currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex flex-col items-center"
|
||||
style={{ flex: 1 }}
|
||||
role="listitem"
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
>
|
||||
<StepCircle
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
stepRefs.current[step.id] = el;
|
||||
}}
|
||||
stepId={step.id}
|
||||
label={step.label}
|
||||
isComplete={isComplete}
|
||||
isCurrent={isCurrent}
|
||||
isUpcoming={isUpcoming}
|
||||
showPopover={showPopover && popoverStep === step.id}
|
||||
onClick={() => handleStepClick(step.id)}
|
||||
/>
|
||||
|
||||
<StepLabel
|
||||
label={step.label}
|
||||
isCurrent={isCurrent}
|
||||
isComplete={isComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Popover */}
|
||||
{showPopover && popoverStep !== null && (
|
||||
<StepPopover
|
||||
ref={popoverRef}
|
||||
stepId={popoverStep}
|
||||
machineStatus={machineStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/WorkflowStepper/index.ts
Normal file
5
src/components/WorkflowStepper/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* WorkflowStepper component barrel export
|
||||
*/
|
||||
|
||||
export { WorkflowStepper } from "./WorkflowStepper";
|
||||
20
src/constants/workflowSteps.ts
Normal file
20
src/constants/workflowSteps.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Workflow step definitions for the embroidery process
|
||||
*/
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const WORKFLOW_STEPS: readonly WorkflowStep[] = [
|
||||
{ id: 1, label: "Connect", description: "Connect to machine" },
|
||||
{ id: 2, label: "Home Machine", description: "Initialize hoop position" },
|
||||
{ id: 3, label: "Load Pattern", description: "Choose PES file" },
|
||||
{ id: 4, label: "Upload", description: "Upload to machine" },
|
||||
{ id: 5, label: "Mask Trace", description: "Trace pattern area" },
|
||||
{ id: 6, label: "Start Sewing", description: "Begin embroidery" },
|
||||
{ id: 7, label: "Monitor", description: "Watch progress" },
|
||||
{ id: 8, label: "Complete", description: "Finish and remove" },
|
||||
] as const;
|
||||
195
src/utils/workflowGuideContent.ts
Normal file
195
src/utils/workflowGuideContent.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* Workflow step guide content
|
||||
*
|
||||
* Provides contextual guidance for each workflow step based on machine state
|
||||
*/
|
||||
|
||||
import { MachineStatus } from "../types/machine";
|
||||
|
||||
export interface GuideContent {
|
||||
type: "info" | "warning" | "success" | "error" | "progress";
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get guide content for a specific workflow step
|
||||
*
|
||||
* @param stepId - The workflow step ID (1-8)
|
||||
* @param machineStatus - Current machine status for dynamic content
|
||||
* @returns Guide content with type, title, description, and items
|
||||
*/
|
||||
export function getGuideContent(
|
||||
stepId: number,
|
||||
machineStatus: MachineStatus,
|
||||
): GuideContent | null {
|
||||
switch (stepId) {
|
||||
case 1:
|
||||
return {
|
||||
type: "info",
|
||||
title: "Step 1: Connect to Machine",
|
||||
description:
|
||||
"To get started, connect to your Brother embroidery machine via Bluetooth.",
|
||||
items: [
|
||||
"Make sure your machine is powered on",
|
||||
"Enable Bluetooth on your machine",
|
||||
'Click the "Connect to Machine" button below',
|
||||
],
|
||||
};
|
||||
|
||||
case 2:
|
||||
return {
|
||||
type: "info",
|
||||
title: "Step 2: Home Machine",
|
||||
description:
|
||||
"The hoop needs to be removed and an initial homing procedure must be performed.",
|
||||
items: [
|
||||
"Remove the embroidery hoop from the machine completely",
|
||||
"Press the Accept button on the machine",
|
||||
"Wait for the machine to complete its initialization (homing)",
|
||||
"Once initialization is complete, reattach the hoop",
|
||||
"The machine should now recognize the hoop correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 3:
|
||||
return {
|
||||
type: "info",
|
||||
title: "Step 3: Load Your Pattern",
|
||||
description:
|
||||
"Choose a PES embroidery file from your computer to preview and upload.",
|
||||
items: [
|
||||
'Click "Choose PES File" in the Pattern File section',
|
||||
"Select your embroidery design (.pes file)",
|
||||
"Review the pattern preview on the right",
|
||||
"You can drag the pattern to adjust its position",
|
||||
],
|
||||
};
|
||||
|
||||
case 4:
|
||||
return {
|
||||
type: "info",
|
||||
title: "Step 4: Upload Pattern to Machine",
|
||||
description:
|
||||
"Send your pattern to the embroidery machine to prepare for sewing.",
|
||||
items: [
|
||||
"Review the pattern preview to ensure it's positioned correctly",
|
||||
"Check the pattern size matches your hoop",
|
||||
'Click "Upload to Machine" when ready',
|
||||
"Wait for the upload to complete (this may take a minute)",
|
||||
],
|
||||
};
|
||||
|
||||
case 5:
|
||||
// Check machine status for substates
|
||||
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
|
||||
return {
|
||||
type: "warning",
|
||||
title: "Machine Action Required",
|
||||
description: "The machine is ready to trace the pattern outline.",
|
||||
items: [
|
||||
"Press the button on your machine to confirm and start the mask trace",
|
||||
"Ensure the hoop is properly attached",
|
||||
"Make sure the needle area is clear",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (machineStatus === MachineStatus.MASK_TRACING) {
|
||||
return {
|
||||
type: "progress",
|
||||
title: "Mask Trace In Progress",
|
||||
description:
|
||||
"The machine is tracing the pattern boundary. Please wait...",
|
||||
items: [
|
||||
"Watch the machine trace the outline",
|
||||
"Verify the pattern fits within your hoop",
|
||||
"Do not interrupt the machine",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "info",
|
||||
title: "Step 5: Start Mask Trace",
|
||||
description:
|
||||
"The mask trace helps the machine understand the pattern boundaries.",
|
||||
items: [
|
||||
'Click "Start Mask Trace" button in the Sewing Progress section',
|
||||
"The machine will trace the pattern outline",
|
||||
"This ensures the hoop is positioned correctly",
|
||||
],
|
||||
};
|
||||
|
||||
case 6:
|
||||
return {
|
||||
type: "success",
|
||||
title: "Step 6: Ready to Sew!",
|
||||
description: "The machine is ready to begin embroidering your pattern.",
|
||||
items: [
|
||||
"Verify your thread colors are correct",
|
||||
"Ensure the fabric is properly hooped",
|
||||
'Click "Start Sewing" when ready',
|
||||
],
|
||||
};
|
||||
|
||||
case 7:
|
||||
// Check for substates
|
||||
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
|
||||
return {
|
||||
type: "warning",
|
||||
title: "Thread Change Required",
|
||||
description:
|
||||
"The machine needs a different thread color to continue.",
|
||||
items: [
|
||||
"Check the color blocks section to see which thread is needed",
|
||||
"Change to the correct thread color",
|
||||
"Press the button on your machine to resume sewing",
|
||||
],
|
||||
};
|
||||
}
|
||||
if (
|
||||
machineStatus === MachineStatus.PAUSE ||
|
||||
machineStatus === MachineStatus.STOP ||
|
||||
machineStatus === MachineStatus.SEWING_INTERRUPTION
|
||||
) {
|
||||
return {
|
||||
type: "warning",
|
||||
title: "Sewing Paused",
|
||||
description: "The embroidery has been paused or interrupted.",
|
||||
items: [
|
||||
"Check if everything is okay with the machine",
|
||||
'Click "Resume Sewing" when ready to continue',
|
||||
"The machine will pick up where it left off",
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "progress",
|
||||
title: "Step 7: Sewing In Progress",
|
||||
description:
|
||||
"Your embroidery is being stitched. Monitor the progress below.",
|
||||
items: [
|
||||
"Watch the progress bar and current stitch count",
|
||||
"The machine will pause when a color change is needed",
|
||||
"Do not leave the machine unattended",
|
||||
],
|
||||
};
|
||||
|
||||
case 8:
|
||||
return {
|
||||
type: "success",
|
||||
title: "Step 8: Embroidery Complete!",
|
||||
description: "Your embroidery is finished. Great work!",
|
||||
items: [
|
||||
"Remove the hoop from the machine",
|
||||
"Press the Accept button on the machine",
|
||||
"Carefully remove your finished embroidery",
|
||||
"Trim any jump stitches or loose threads",
|
||||
'Click "Delete Pattern" to start a new project',
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
56
src/utils/workflowStepCalculation.ts
Normal file
56
src/utils/workflowStepCalculation.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Workflow step calculation utilities
|
||||
*
|
||||
* Determines the current workflow step based on machine state and pattern status
|
||||
*/
|
||||
|
||||
import { MachineStatus } from "../types/machine";
|
||||
|
||||
/**
|
||||
* Calculate the current workflow step based on machine state
|
||||
*
|
||||
* @param machineStatus - Current machine status
|
||||
* @param isConnected - Whether machine is connected
|
||||
* @param hasPattern - Whether a pattern is loaded
|
||||
* @param patternUploaded - Whether pattern has been uploaded to machine
|
||||
* @returns Current step number (1-8)
|
||||
*/
|
||||
export function getCurrentStep(
|
||||
machineStatus: MachineStatus,
|
||||
isConnected: boolean,
|
||||
hasPattern: boolean,
|
||||
patternUploaded: boolean,
|
||||
): number {
|
||||
if (!isConnected) return 1;
|
||||
|
||||
// Check if machine needs homing (Initial state)
|
||||
if (machineStatus === MachineStatus.Initial) return 2;
|
||||
|
||||
if (!hasPattern) return 3;
|
||||
if (!patternUploaded) return 4;
|
||||
|
||||
// After upload, determine step based on machine status
|
||||
switch (machineStatus) {
|
||||
case MachineStatus.IDLE:
|
||||
case MachineStatus.MASK_TRACE_LOCK_WAIT:
|
||||
case MachineStatus.MASK_TRACING:
|
||||
return 5;
|
||||
|
||||
case MachineStatus.MASK_TRACE_COMPLETE:
|
||||
case MachineStatus.SEWING_WAIT:
|
||||
return 6;
|
||||
|
||||
case MachineStatus.SEWING:
|
||||
case MachineStatus.COLOR_CHANGE_WAIT:
|
||||
case MachineStatus.PAUSE:
|
||||
case MachineStatus.STOP:
|
||||
case MachineStatus.SEWING_INTERRUPTION:
|
||||
return 7;
|
||||
|
||||
case MachineStatus.SEWING_COMPLETE:
|
||||
return 8;
|
||||
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue