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
329 lines
15 KiB
TypeScript
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>
|
|
);
|
|
};
|