diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..d21cf3c3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(npm run build:*)", + "Bash(npm run type-check:*)", + "Bash(npm run:*)", + "Bash(move:*)", + "Bash(mv:*)", + "Bash(grep:*)", + "Bash(rm:*)", + "Bash(rm:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.cursor/rules/antd-components.mdc b/.cursor/rules/antd-components.mdc new file mode 100644 index 00000000..7db5eb05 --- /dev/null +++ b/.cursor/rules/antd-components.mdc @@ -0,0 +1,237 @@ +--- +alwaysApply: true +--- +# Ant Design Import Rules for Worklenz + +## 🚨 CRITICAL: Always Use Centralized Imports + +**NEVER import Ant Design components directly from 'antd' or '@ant-design/icons'** + +### ✅ Correct Import Pattern +```typescript +import { Button, Input, Select, EditOutlined, PlusOutlined } from '@antd-imports'; +// or +import { Button, Input, Select, EditOutlined, PlusOutlined } from '@/shared/antd-imports'; +``` + +### ❌ Forbidden Import Patterns +```typescript +// NEVER do this: +import { Button, Input, Select } from 'antd'; +import { EditOutlined, PlusOutlined } from '@ant-design/icons'; +``` + +## Why This Rule Exists + +### Benefits of Centralized Imports: +- **Better Tree-Shaking**: Optimized bundle size through centralized management +- **Consistent React Context**: Proper context sharing across components +- **Type Safety**: Centralized TypeScript definitions +- **Maintainability**: Single source of truth for all Ant Design imports +- **Performance**: Reduced bundle size and improved loading times + +## What's Available in `@antd-imports` + +### Core Components +- **Layout**: Layout, Row, Col, Flex, Divider, Space +- **Navigation**: Menu, Tabs, Breadcrumb, Pagination +- **Data Entry**: Input, Select, DatePicker, TimePicker, Form, Checkbox, InputNumber +- **Data Display**: Table, List, Card, Tag, Avatar, Badge, Progress, Statistic +- **Feedback**: Modal, Drawer, Alert, Message, Notification, Spin, Skeleton, Result +- **Other**: Button, Typography, Tooltip, Popconfirm, Dropdown, ConfigProvider + +### Icons +Common icons including: EditOutlined, DeleteOutlined, PlusOutlined, MoreOutlined, CheckOutlined, CloseOutlined, CalendarOutlined, UserOutlined, TeamOutlined, and many more. + +### Utilities +- **appMessage**: Centralized message utility +- **appNotification**: Centralized notification utility +- **antdConfig**: Default Ant Design configuration +- **taskManagementAntdConfig**: Task-specific configuration + +## Implementation Guidelines + +### When Creating New Components: +1. **Always** import from `@/shared/antd-imports` +2. Use `appMessage` and `appNotification` for user feedback +3. Apply `antdConfig` for consistent styling +4. Use `taskManagementAntdConfig` for task-related components + +### When Refactoring Existing Code: +1. Replace direct 'antd' imports with `@/shared/antd-imports` +2. Replace direct '@ant-design/icons' imports with `@/shared/antd-imports` +3. Update any custom message/notification calls to use the utilities + +### File Location +The centralized import file is located at: `worklenz-frontend/src/shared/antd-imports.ts` + +## Examples + +### Component Creation +```typescript +import React from 'react'; +import { Button, Input, Modal, EditOutlined, appMessage } from '@antd-imports'; + +const MyComponent = () => { + const handleClick = () => { + appMessage.success('Operation completed!'); + }; + + return ( + + ); +}; +``` + +### Form Implementation +```typescript +import { Form, Input, Select, Button, DatePicker } from '@antd-imports'; + +const MyForm = () => { + return ( +
+ + + + + + + + updateEmail(teamMember.id, e.target.value)} - onPressEnter={handleKeyPress} - ref={setInputRef(index)} - status={teamMember.value && !validateEmail(teamMember.value) ? 'error' : ''} - id={`member-${index}`} + onKeyPress={e => handleKeyPress(e, index)} + onFocus={() => setFocusedIndex(index)} + onBlur={() => handleBlur(teamMember.id, teamMember.value)} + ref={el => inputRefs.current[index] = el} + className="border-0 shadow-none" + style={{ + backgroundColor: 'transparent', + color: token?.colorText + }} + prefix={} + status={emailStatus === 'invalid' ? 'error' : undefined} + suffix={ + emailStatus === 'valid' ? ( + + ) : emailStatus === 'invalid' ? ( + + ) : null + } /> - - -
+ + {/* Add Member Button */} + {teamMembers.length < 5 && ( + + )} +
+ + {/* Skip Option */} +
+
- -
+ /> + + ); }; -export default MembersStep; +export default MembersStep; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/organization-step.tsx b/worklenz-frontend/src/components/account-setup/organization-step.tsx index c61d1eee..87e4ab9a 100644 --- a/worklenz-frontend/src/components/account-setup/organization-step.tsx +++ b/worklenz-frontend/src/components/account-setup/organization-step.tsx @@ -1,31 +1,40 @@ -import React, { useEffect, useRef } from 'react'; -import { Form, Input, InputRef, Typography } from 'antd'; +import React, { useEffect, useRef, useState } from 'react'; +import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { setOrganizationName } from '@/features/account-setup/account-setup.slice'; import { RootState } from '@/app/store'; import { sanitizeInput } from '@/utils/sanitizeInput'; -import './admin-center-common.css'; -const { Title } = Typography; +const { Title, Paragraph, Text } = Typography; interface Props { onEnter: () => void; styles: any; organizationNamePlaceholder: string; + organizationNameInitialValue?: string; + isDarkMode: boolean; + token?: any; } export const OrganizationStep: React.FC = ({ onEnter, styles, organizationNamePlaceholder, + organizationNameInitialValue, + isDarkMode, + token, }) => { const { t } = useTranslation('account-setup'); const dispatch = useDispatch(); const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer); const inputRef = useRef(null); + // Autofill organization name if not already set useEffect(() => { + if (!organizationName && organizationNameInitialValue) { + dispatch(setOrganizationName(organizationNameInitialValue)); + } setTimeout(() => inputRef.current?.focus(), 300); }, []); @@ -40,25 +49,85 @@ export const OrganizationStep: React.FC = ({ }; return ( -
- - - {t('organizationStepTitle')} + <div className="w-full organization-step"> + {/* Header */} + <div className="text-center mb-8"> + <Title level={3} className="mb-2" style={{ color: token?.colorText }}> + {t('organizationStepWelcome')} - - {t('organizationStepLabel')}} + + {t('organizationStepDescription')} + + + + {/* Main Form Card */} +
+ + + + {t('organizationStepLabel')} + + + + ⓘ + + +
+ } + > + +
+ + {/* Character Count and Validation */} +
+ + {organizationName.length}/50 {t('organizationStepCharacters')} + + {organizationName.length > 0 && ( +
+ {organizationName.length >= 2 ? ( + ✓ {t('organizationStepGoodLength')} + ) : ( + ⚠ {t('organizationStepTooShort')} + )} +
+ )} +
+ + + + {/* Footer Note */} +
- - - + + 🔒 {t('organizationStepPrivacyNote')} + +
+ ); -}; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/project-step.tsx b/worklenz-frontend/src/components/account-setup/project-step.tsx index ec42b8d0..4810859b 100644 --- a/worklenz-frontend/src/components/account-setup/project-step.tsx +++ b/worklenz-frontend/src/components/account-setup/project-step.tsx @@ -1,9 +1,9 @@ import React, { startTransition, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { Button, Drawer, Form, Input, InputRef, Select, Typography } from 'antd'; +import { Button, Drawer, Form, Input, InputRef, Typography, Card, Row, Col, Tag, Tooltip, Spin, Alert } from '@/shared/antd-imports'; import TemplateDrawer from '../common/template-drawer/template-drawer'; import { RootState } from '@/app/store'; @@ -13,23 +13,56 @@ import { sanitizeInput } from '@/utils/sanitizeInput'; import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service'; import logger from '@/utils/errorLogger'; -import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types'; +import { IAccountSetupRequest, IWorklenzTemplate, IProjectTemplate } from '@/types/project-templates/project-templates.types'; import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { createPortal } from 'react-dom'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { verifyAuthentication } from '@/features/auth/authSlice'; +import { setUser } from '@/features/user/userSlice'; +import { setSession } from '@/utils/session-helper'; +import { IAuthorizeResponse } from '@/types/auth/login.types'; -const { Title } = Typography; +const { Title, Paragraph, Text } = Typography; interface Props { onEnter: () => void; styles: any; isDarkMode: boolean; + token?: any; } -export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = false }) => { +// Default icon mapping for templates (fallback if no image_url) +const getTemplateIcon = (name?: string) => { + if (!name) return '📁'; + const lowercaseName = name.toLowerCase(); + if (lowercaseName.includes('software') || lowercaseName.includes('development')) return '💻'; + if (lowercaseName.includes('marketing') || lowercaseName.includes('campaign')) return '📢'; + if (lowercaseName.includes('construction') || lowercaseName.includes('building')) return '🏗️'; + if (lowercaseName.includes('startup') || lowercaseName.includes('launch')) return '🚀'; + if (lowercaseName.includes('design') || lowercaseName.includes('creative')) return '🎨'; + if (lowercaseName.includes('education') || lowercaseName.includes('learning')) return '📚'; + if (lowercaseName.includes('event') || lowercaseName.includes('planning')) return '📅'; + if (lowercaseName.includes('retail') || lowercaseName.includes('sales')) return '🛍️'; + return '📁'; +}; + +const getProjectSuggestions = (orgType?: string) => { + const suggestions: Record = { + 'freelancer': ['Client Website', 'Logo Design', 'Content Writing', 'App Development'], + 'startup': ['MVP Development', 'Product Launch', 'Marketing Campaign', 'Investor Pitch'], + 'small_medium_business': ['Q1 Sales Initiative', 'Website Redesign', 'Process Improvement', 'Team Training'], + 'agency': ['Client Campaign', 'Brand Strategy', 'Website Project', 'Creative Brief'], + 'enterprise': ['Digital Transformation', 'System Migration', 'Annual Planning', 'Department Initiative'], + 'other': ['New Project', 'Team Initiative', 'Q1 Goals', 'Special Project'] + }; + return suggestions[orgType || 'other'] || suggestions['other']; +}; + +export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = false, token }) => { const { t } = useTranslation('account-setup'); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const navigate = useNavigate(); const { trackMixpanelEvent } = useMixpanelTracking(); @@ -37,13 +70,58 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal useEffect(() => { setTimeout(() => inputRef.current?.focus(), 200); + fetchTemplates(); }, []); - const { projectName, templateId, organizationName } = useSelector( + const fetchTemplates = async () => { + try { + setLoadingTemplates(true); + setTemplateError(null); + + // Fetch list of available templates + const templatesResponse = await projectTemplatesApiService.getWorklenzTemplates(); + + if (templatesResponse.done && templatesResponse.body) { + // Fetch detailed information for first 4 templates for preview + const templateDetails = await Promise.all( + templatesResponse.body.slice(0, 4).map(async (template) => { + if (template.id) { + try { + const detailResponse = await projectTemplatesApiService.getByTemplateId(template.id); + return detailResponse.done ? detailResponse.body : null; + } catch (error) { + logger.error(`Failed to fetch template details for ${template.id}`, error); + return null; + } + } + return null; + }) + ); + + // Filter out null results and set templates + const validTemplates = templateDetails.filter((template): template is IProjectTemplate => template !== null); + setTemplates(validTemplates); + } + } catch (error) { + logger.error('Failed to fetch templates', error); + setTemplateError('Failed to load templates'); + } finally { + setLoadingTemplates(false); + } + }; + + + const { projectName, templateId, organizationName, surveyData } = useSelector( (state: RootState) => state.accountSetupReducer ); const [open, setOpen] = useState(false); const [creatingFromTemplate, setCreatingFromTemplate] = useState(false); + const [selectedTemplate, setSelectedTemplate] = useState(templateId || null); + const [templates, setTemplates] = useState([]); + const [loadingTemplates, setLoadingTemplates] = useState(true); + const [templateError, setTemplateError] = useState(null); + + const projectSuggestions = getProjectSuggestions(surveyData.organization_type); const handleTemplateSelected = (templateId: string) => { if (!templateId) return; @@ -69,6 +147,15 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal if (res.done && res.body.id) { toggleTemplateSelector(false); trackMixpanelEvent(evt_account_setup_template_complete); + try { + const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; + if (authResponse?.authenticated && authResponse?.user) { + setSession(authResponse.user); + dispatch(setUser(authResponse.user)); + } + } catch (error) { + logger.error('Failed to refresh user session after template setup completion', error); + } navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`); } } catch (error) { @@ -77,8 +164,7 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal }; const onPressEnter = () => { - if (!projectName.trim()) return; - onEnter(); + if (projectName.trim()) onEnter(); }; const handleProjectNameChange = (e: React.ChangeEvent) => { @@ -86,43 +172,205 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal dispatch(setProjectName(sanitizedValue)); }; + const handleProjectNameFocus = () => { + if (templateId) { + dispatch(setTemplateId(null)); + setSelectedTemplate(null); + } + }; + + const handleSuggestionClick = (suggestion: string) => { + dispatch(setProjectName(suggestion)); + inputRef.current?.focus(); + }; + return ( -
-
- - - {t('projectStepTitle')} - - - {t('projectStepLabel')}} - > - - -
-
- - {t('or')} + <div className="w-full project-step"> + {/* Header */} + <div className="text-center mb-8"> + <Title level={3} className="mb-2" style={{ color: token?.colorText }}> + {t('projectStepHeader')} -
+ + {t('projectStepSubheader')} +
-
- + {/* Project Name Section */} +
+ +
+
+ + {t('startFromScratch')} + + {templateId && ( + + {t('templateSelected')} + + )} +
+
+ + {t('projectStepLabel')}} + > + + + +
+ {t('quickSuggestions')} +
+ {projectSuggestions.map((suggestion, index) => ( + + ))} +
+
+
+ +
+
+
+
+
+ {t('orText')} +
+
+ +
+
+ {t('startWithTemplate')} + + {t('templateHeadStart')} + +
+ + {/* Template Preview Cards */} +
+ {loadingTemplates ? ( +
+ +
+ Loading templates... +
+
+ ) : templateError ? ( + + Retry + + } + /> + ) : ( + + {templates.map((template) => ( + + { + setSelectedTemplate(template.id || null); + dispatch(setTemplateId(template.id || '')); + }} + > +
+ {template.image_url ? ( + {template.name} { + // Fallback to icon if image fails to load + e.currentTarget.style.display = 'none'; + if (e.currentTarget.nextSibling) { + (e.currentTarget.nextSibling as HTMLElement).style.display = 'block'; + } + }} + /> + ) : null} + + {getTemplateIcon(template.name)} + +
+ + {template.name || 'Untitled Template'} + +
+ {template.phases?.slice(0, 3).map((phase, index) => ( + + {phase.name} + + ))} + {(template.phases?.length || 0) > 3 && ( + +{(template.phases?.length || 0) - 3} more + )} +
+
+
+
+ + ))} +
+ )} +
+ +
+ +
+ {t('templatesAvailable')} +
+
+
+ + {/* Template Drawer */} {createPortal( + + {t('templateDrawerTitle')} + + + {t('chooseTemplate')} + +
+ } width={1000} onClose={() => toggleTemplateSelector(false)} open={open} @@ -135,11 +383,13 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal type="primary" onClick={() => createFromTemplate()} loading={creatingFromTemplate} + disabled={!templateId} > - {t('create')} +{t('createProject')}
} + style={{ backgroundColor: token?.colorBgLayout }} > = ({ onEnter, styles, isDarkMode = fal )}
); -}; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/account-setup/survey-step.tsx b/worklenz-frontend/src/components/account-setup/survey-step.tsx new file mode 100644 index 00000000..17efc3d2 --- /dev/null +++ b/worklenz-frontend/src/components/account-setup/survey-step.tsx @@ -0,0 +1,374 @@ +import React from 'react'; +import { Form, Input, Typography, Button, Progress, Space } from '@/shared/antd-imports'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { setSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice'; +import { RootState } from '@/app/store'; +import { + OrganizationType, + UserRole, + UseCase, + HowHeardAbout, + IAccountSetupSurveyData +} from '@/types/account-setup/survey.types'; + +const { Title, Paragraph } = Typography; +const { TextArea } = Input; + +interface Props { + onEnter: () => void; + styles: any; + isDarkMode: boolean; + token?: any; + isModal?: boolean; // New prop to indicate if used in modal context +} + +interface SurveyPageProps { + styles: any; + isDarkMode: boolean; + token?: any; + surveyData: IAccountSetupSurveyData; + handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void; + handleUseCaseToggle?: (value: UseCase) => void; + isModal?: boolean; +} + +// Page 1: About You +const AboutYouPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange }) => { + const { t } = useTranslation('account-setup'); + + const organizationTypeOptions: { value: OrganizationType; label: string; icon?: string }[] = [ + { value: 'freelancer', label: t('organizationTypeFreelancer'), icon: '👤' }, + { value: 'startup', label: t('organizationTypeStartup'), icon: '🚀' }, + { value: 'small_medium_business', label: t('organizationTypeSmallMediumBusiness'), icon: '🏢' }, + { value: 'agency', label: t('organizationTypeAgency'), icon: '🎯' }, + { value: 'enterprise', label: t('organizationTypeEnterprise'), icon: '🏛️' }, + { value: 'other', label: t('organizationTypeOther'), icon: '📋' }, + ]; + + const userRoleOptions: { value: UserRole; label: string; icon?: string }[] = [ + { value: 'founder_ceo', label: t('userRoleFounderCeo'), icon: '👔' }, + { value: 'project_manager', label: t('userRoleProjectManager'), icon: '📊' }, + { value: 'software_developer', label: t('userRoleSoftwareDeveloper'), icon: '💻' }, + { value: 'designer', label: t('userRoleDesigner'), icon: '🎨' }, + { value: 'operations', label: t('userRoleOperations'), icon: '⚙️' }, + { value: 'other', label: t('userRoleOther'), icon: '✋' }, + ]; + + return ( +
+
+ + {t('aboutYouStepTitle')} + + + {t('aboutYouStepDescription')} + +
+ + {/* Organization Type */} + + +
+ {organizationTypeOptions.map((option) => { + const isSelected = surveyData.organization_type === option.value; + return ( + + ); + })} +
+
+ + {/* User Role */} + + +
+ {userRoleOptions.map((option) => { + const isSelected = surveyData.user_role === option.value; + return ( + + ); + })} +
+
+
+ ); +}; + +// Page 2: Your Needs +const YourNeedsPage: React.FC = ({ styles, token, surveyData, handleSurveyDataChange, handleUseCaseToggle }) => { + const { t } = useTranslation('account-setup'); + + const useCaseOptions: { value: UseCase; label: string; description: string }[] = [ + { value: 'task_management', label: t('mainUseCasesTaskManagement'), description: 'Organize and track tasks' }, + { value: 'team_collaboration', label: t('mainUseCasesTeamCollaboration'), description: 'Work together seamlessly' }, + { value: 'resource_planning', label: t('mainUseCasesResourcePlanning'), description: 'Manage time and resources' }, + { value: 'client_communication', label: t('mainUseCasesClientCommunication'), description: 'Stay connected with clients' }, + { value: 'time_tracking', label: t('mainUseCasesTimeTracking'), description: 'Monitor project hours' }, + { value: 'other', label: t('mainUseCasesOther'), description: 'Something else' }, + ]; + + const onUseCaseClick = (value: UseCase) => { + if (handleUseCaseToggle) { + handleUseCaseToggle(value); + } else { + const currentUseCases = surveyData.main_use_cases || []; + const isSelected = currentUseCases.includes(value); + const newUseCases = isSelected ? currentUseCases.filter(useCase => useCase !== value) : [...currentUseCases, value]; + handleSurveyDataChange('main_use_cases', newUseCases); + } + }; + + return ( +
+
+ + {t('yourNeedsStepTitle')} + + + {t('yourNeedsStepDescription')} + +
+ + {/* Main Use Cases */} + + +
+ {useCaseOptions.map((option) => { + const isSelected = (surveyData.main_use_cases || []).includes(option.value); + return ( + + ); + })} +
+ {surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && ( +

+ {surveyData.main_use_cases.length} {t('selected')} +

+ )} +
+ + {/* Previous Tools */} + + +