Files
bakery-ia/frontend/src/components/ui/WizardModal/WizardModal.tsx
Claude 877e0b6b47 Implement Phase 2: Recipe & Supplier wizard modals (JTBD-driven UX)
Following Jobs-To-Be-Done analysis, break down complex forms into multi-step wizards to reduce cognitive load for non-technical bakery owners.

**Core Infrastructure:**
- Add reusable WizardModal component with progress tracking, validation, and navigation
  - Multi-step progress bar with clickable previous steps
  - Per-step validation with clear error messaging
  - Back/Next/Complete navigation with loading states
  - Optional step skipping support
  - Responsive modal design (sm/md/lg/xl/2xl sizes)

**Recipe Wizard (4 steps):**
- Step 1 (Product): Name, category, finished product, cuisine type, difficulty, description
- Step 2 (Ingredients): Dynamic ingredient list with add/remove, quantities, units, optional flags
- Step 3 (Production): Times (prep/cook/rest), yield, batch sizes, temperature, humidity, special flags
- Step 4 (Review): Instructions, storage, nutritional info, allergens, final summary

**Supplier Wizard (3 steps):**
- Step 1 (Basic): Name, type, status, contact person, email, phone, tax ID, registration
- Step 2 (Delivery): Payment terms, lead time, minimum order, delivery schedule, address
- Step 3 (Review): Certifications, sustainability practices, notes, summary

**Benefits:**
- Reduces form overwhelm from 8 sections to 4 sequential steps (recipes) and 3 steps (suppliers)
- Clear progress indication and next actions
- Validation feedback per step instead of at end
- Summary review before final submission
- Matches mental model of "configure then review" workflow

Files:
- WizardModal: Reusable wizard infrastructure
- RecipeWizard: 4-step recipe creation (Product → Ingredients → Production → Review)
- SupplierWizard: 3-step supplier creation (Basic → Delivery → Review)

Related to Phase 1 (ConfigurationProgressWidget) for post-onboarding guidance.
2025-11-06 18:01:11 +00:00

272 lines
9.0 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
export interface WizardStep {
id: string;
title: string;
description?: string;
component: React.ComponentType<WizardStepProps>;
isOptional?: boolean;
validate?: () => Promise<boolean> | boolean;
}
export interface WizardStepProps {
onNext: () => void;
onBack: () => void;
onComplete: () => void;
isFirstStep: boolean;
isLastStep: boolean;
currentStepIndex: number;
totalSteps: number;
goToStep: (index: number) => void;
}
interface WizardModalProps {
isOpen: boolean;
onClose: () => void;
onComplete: (data?: any) => void;
title: string;
steps: WizardStep[];
icon?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
export const WizardModal: React.FC<WizardModalProps> = ({
isOpen,
onClose,
onComplete,
title,
steps,
icon,
size = 'xl'
}) => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isValidating, setIsValidating] = useState(false);
const currentStep = steps[currentStepIndex];
const isFirstStep = currentStepIndex === 0;
const isLastStep = currentStepIndex === steps.length - 1;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
'2xl': 'max-w-7xl'
};
const handleNext = useCallback(async () => {
// Validate current step if validator exists
if (currentStep.validate) {
setIsValidating(true);
try {
const isValid = await currentStep.validate();
if (!isValid) {
setIsValidating(false);
return;
}
} catch (error) {
console.error('Validation error:', error);
setIsValidating(false);
return;
}
setIsValidating(false);
}
if (isLastStep) {
handleComplete();
} else {
setCurrentStepIndex(prev => Math.min(prev + 1, steps.length - 1));
}
}, [currentStep, isLastStep, steps.length]);
const handleBack = useCallback(() => {
setCurrentStepIndex(prev => Math.max(prev - 1, 0));
}, []);
const handleComplete = useCallback(() => {
onComplete();
handleClose();
}, [onComplete]);
const handleClose = useCallback(() => {
setCurrentStepIndex(0);
onClose();
}, [onClose]);
const goToStep = useCallback((index: number) => {
if (index >= 0 && index < steps.length) {
setCurrentStepIndex(index);
}
}, [steps.length]);
if (!isOpen) return null;
const StepComponent = currentStep.component;
const progressPercentage = ((currentStepIndex + 1) / steps.length) * 100;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-50 animate-fadeIn"
onClick={handleClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className={`bg-[var(--bg-primary)] rounded-xl shadow-2xl ${sizeClasses[size]} w-full max-h-[90vh] overflow-hidden pointer-events-auto animate-slideUp`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 bg-[var(--bg-primary)] border-b border-[var(--border-secondary)]">
{/* Title Bar */}
<div className="flex items-center justify-between p-6 pb-4">
<div className="flex items-center gap-3">
{icon && (
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center text-[var(--color-primary)]">
{icon}
</div>
)}
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{title}
</h2>
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
{currentStep.description || `Step ${currentStepIndex + 1} of ${steps.length}`}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Progress Bar */}
<div className="px-6 pb-4">
<div className="flex items-center gap-2 mb-2">
{steps.map((step, index) => (
<React.Fragment key={step.id}>
<button
onClick={() => index < currentStepIndex && goToStep(index)}
disabled={index > currentStepIndex}
className={`flex-1 h-2 rounded-full transition-all duration-300 ${
index < currentStepIndex
? 'bg-[var(--color-success)] cursor-pointer hover:bg-[var(--color-success)]/80'
: index === currentStepIndex
? 'bg-[var(--color-primary)]'
: 'bg-[var(--bg-tertiary)] cursor-not-allowed'
}`}
title={step.title}
/>
</React.Fragment>
))}
</div>
<div className="flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span className="font-medium">{currentStep.title}</span>
<span>{currentStepIndex + 1} / {steps.length}</span>
</div>
</div>
</div>
{/* Step Content */}
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(90vh - 220px)' }}>
<StepComponent
onNext={handleNext}
onBack={handleBack}
onComplete={handleComplete}
isFirstStep={isFirstStep}
isLastStep={isLastStep}
currentStepIndex={currentStepIndex}
totalSteps={steps.length}
goToStep={goToStep}
/>
</div>
{/* Footer Navigation */}
<div className="sticky bottom-0 border-t border-[var(--border-secondary)] bg-[var(--bg-secondary)]/50 backdrop-blur-sm px-6 py-4">
<div className="flex items-center justify-between gap-3">
{/* Back Button */}
{!isFirstStep && (
<button
onClick={handleBack}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
)}
<div className="flex-1" />
{/* Skip Button (for optional steps) */}
{currentStep.isOptional && !isLastStep && (
<button
onClick={() => setCurrentStepIndex(prev => prev + 1)}
disabled={isValidating}
className="px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors font-medium disabled:opacity-50"
>
Skip This Step
</button>
)}
{/* Next/Complete Button */}
<button
onClick={handleNext}
disabled={isValidating}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium inline-flex items-center gap-2 min-w-[140px] justify-center"
>
{isValidating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Validating...
</>
) : isLastStep ? (
<>
Complete
<ChevronRight className="w-4 h-4" />
</>
) : (
<>
Next
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
</div>
{/* Animation Styles */}
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
.animate-slideUp {
animation: slideUp 0.3s ease-out;
}
`}</style>
</>
);
};