Files
bakery-ia/frontend/src/components/domain/setup-wizard/steps/TeamSetupStep.tsx
Claude 623d378faf Architect navigation buttons correctly: move from wizard-level to step-level
Fixed the navigation architecture to follow proper onboarding patterns:

**ARCHITECTURE CHANGE:**
- REMOVED: External navigation footer from UnifiedOnboardingWizard (Back + Continue buttons at wizard level)
- ADDED: Internal Continue buttons inside each setup wizard step component

**WHY THIS MATTERS:**
1. Onboarding should NEVER show Back buttons (users cannot go back)
2. Each step should be self-contained with its own Continue button
3. Setup wizard steps are reused in both contexts:
   - SetupWizard (/app/setup): Uses external StepNavigation component
   - UnifiedOnboardingWizard: Steps now render their own buttons

**CHANGES MADE:**

1. UnifiedOnboardingWizard.tsx:
   - Removed navigation footer (lines 548-588)
   - Now passes canContinue prop to steps
   - Steps are responsible for their own navigation

2. All setup wizard steps updated:
   - QualitySetupStep: Added onComplete, canContinue props + Continue button
   - SuppliersSetupStep: Modified existing button to call onComplete
   - InventorySetupStep: Added onComplete, canContinue props + Continue button
   - RecipesSetupStep: Added canContinue prop + Continue button
   - TeamSetupStep: Added onComplete, canContinue props + Continue button
   - ReviewSetupStep: Added onComplete, canContinue props + Continue button

3. Continue button pattern:
   - Only renders when onComplete prop exists (onboarding context)
   - Disabled based on canContinue prop from parent
   - Styled consistently across all steps
   - Positioned at bottom with border-top separator

**RESULT:**
- Clean separation: onboarding steps have internal buttons, no external navigation
- No Back button in onboarding (as required)
- Setup wizard still works with external StepNavigation
- Consistent UX across all steps
2025-11-06 19:55:42 +00:00

329 lines
15 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { SetupStepProps } from '../SetupWizard';
interface TeamMember {
id: string;
name: string;
email: string;
role: string;
}
export const TeamSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, canContinue }) => {
const { t } = useTranslation();
// Local state for team members (will be sent to backend when API is available)
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
role: 'baker',
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Notify parent - Team step is always optional, so always canContinue
useEffect(() => {
onUpdate?.({
itemsCount: teamMembers.length,
canContinue: true, // Always true since this step is optional
});
}, [teamMembers.length, onUpdate]);
// Validation
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:team.errors.name_required', 'Name is required');
}
if (!formData.email.trim()) {
newErrors.email = t('setup_wizard:team.errors.email_required', 'Email is required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('setup_wizard:team.errors.email_invalid', 'Invalid email format');
}
// Check for duplicate email
if (teamMembers.some((member) => member.email.toLowerCase() === formData.email.toLowerCase())) {
newErrors.email = t('setup_wizard:team.errors.email_duplicate', 'This email is already added');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Form handlers
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
// Add team member to local state
const newMember: TeamMember = {
id: Date.now().toString(),
name: formData.name,
email: formData.email,
role: formData.role,
};
setTeamMembers([...teamMembers, newMember]);
// Reset form
resetForm();
};
const resetForm = () => {
setFormData({
name: '',
email: '',
role: 'baker',
});
setErrors({});
setIsAdding(false);
};
const handleRemove = (memberId: string) => {
setTeamMembers(teamMembers.filter((member) => member.id !== memberId));
};
const roleOptions = [
{ value: 'admin', label: t('team:role.admin', 'Admin'), icon: '👑', description: t('team:role.admin_desc', 'Full access') },
{ value: 'manager', label: t('team:role.manager', 'Manager'), icon: '📊', description: t('team:role.manager_desc', 'Can manage operations') },
{ value: 'baker', label: t('team:role.baker', 'Baker'), icon: '👨‍🍳', description: t('team:role.baker_desc', 'Production staff') },
{ value: 'cashier', label: t('team:role.cashier', 'Cashier'), icon: '💰', description: t('team:role.cashier_desc', 'Sales and POS') },
];
return (
<div className="space-y-6">
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('setup_wizard:why_this_matters', 'Why This Matters')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:team.why', 'Adding team members allows you to assign tasks, track who does what, and give everyone the tools they need to work efficiently.')}
</p>
</div>
{/* Optional badge */}
<div className="flex items-center gap-2 text-sm">
<span className="px-2 py-1 bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-full border border-[var(--border-secondary)]">
{t('setup_wizard:optional', 'Optional')}
</span>
<span className="text-[var(--text-tertiary)]">
{t('setup_wizard:team.optional_note', 'You can add team members now or invite them later from settings')}
</span>
</div>
{/* Info note about future invitations */}
{teamMembers.length > 0 && (
<div className="flex items-start gap-2 p-3 bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg">
<svg className="w-5 h-5 text-[var(--color-info)] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:team.invitation_note', 'Team members will receive invitation emails once you complete the setup wizard.')}
</div>
</div>
)}
{/* Progress indicator */}
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:team.added_count', { count: teamMembers.length, defaultValue: '{{count}} team member added' })}
</span>
</div>
</div>
{/* Team members list */}
{teamMembers.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
{t('setup_wizard:team.your_team', 'Your Team Members')}
</h4>
<div className="space-y-2 max-h-80 overflow-y-auto">
{teamMembers.map((member) => (
<div
key={member.id}
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-3">
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-lg">
{roleOptions.find(opt => opt.value === member.role)?.icon || '👤'}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h5 className="font-medium text-[var(--text-primary)] truncate">{member.name}</h5>
<span className="text-xs px-2 py-0.5 bg-[var(--bg-primary)] rounded-full text-[var(--text-secondary)]">
{roleOptions.find(opt => opt.value === member.role)?.label || member.role}
</span>
</div>
<p className="text-xs text-[var(--text-secondary)] truncate">{member.email}</p>
</div>
</div>
<button
type="button"
onClick={() => handleRemove(member.id)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors ml-2"
aria-label={t('common:remove', 'Remove')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{/* Add form */}
{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{t('setup_wizard:team.add_member', 'Add Team Member')}
</h4>
<button
type="button"
onClick={resetForm}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
<div className="space-y-4">
{/* Name */}
<div>
<label htmlFor="member-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:team.fields.name', 'Full Name')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="member-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:team.placeholders.name', 'e.g., María García')}
/>
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
</div>
{/* Email */}
<div>
<label htmlFor="member-email" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:team.fields.email', 'Email Address')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="member-email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.email ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:team.placeholders.email', 'e.g., maria@panaderia.com')}
/>
{errors.email && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.email}</p>}
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
{t('setup_wizard:team.fields.role', 'Role')} <span className="text-[var(--color-error)]">*</span>
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{roleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData({ ...formData, role: option.value })}
className={`p-3 text-left border rounded-lg transition-colors ${
formData.role === option.value
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10'
: 'border-[var(--border-secondary)] hover:border-[var(--border-primary)]'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{option.icon}</span>
<span className="text-sm font-medium text-[var(--text-primary)]">{option.label}</span>
</div>
<p className="text-xs text-[var(--text-secondary)]">{option.description}</p>
</button>
))}
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
>
{t('common:add', 'Add')}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium">
{teamMembers.length === 0
? t('setup_wizard:team.add_first', 'Add Your First Team Member')
: t('setup_wizard:team.add_another', 'Add Another Team Member')}
</span>
</div>
</button>
)}
{/* Skip message */}
{teamMembers.length === 0 && !isAdding && (
<div className="text-center py-6">
<p className="text-sm text-[var(--text-secondary)] mb-2">
{t('setup_wizard:team.skip_message', 'Working alone for now? No problem!')}
</p>
<p className="text-xs text-[var(--text-tertiary)]">
{t('setup_wizard:team.skip_hint', 'You can always invite team members later from Settings → Team')}
</p>
</div>
)}
{/* Continue button - only shown when used in onboarding context */}
{onComplete && (
<div className="flex justify-end mt-6 pt-6 border-t border-[var(--border-secondary)]">
<button
onClick={onComplete}
disabled={canContinue === false}
className="px-6 py-3 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"
>
{t('setup_wizard:navigation.continue', 'Continue →')}
</button>
</div>
)}
</div>
);
};