Compare commits

...

25 Commits

Author SHA1 Message Date
chamikaJ
aa00f6fe21 Merge branches 'feature/guest-user' and 'main' of https://github.com/Worklenz/worklenz into feature/guest-user 2025-07-24 10:19:12 +05:30
Chamika J
fe3fb5e627 Merge pull request #285 from Worklenz/development
Development
2025-07-24 10:17:22 +05:30
Chamika J
ef47df804f Merge pull request #284 from Worklenz/fix/release-v2.1.3
feat(database): add performance indexes for optimized task queries
2025-07-24 10:16:34 +05:30
chamikaJ
7ea05d2982 feat(database): add performance indexes for optimized task queries
- Introduced multiple new indexes in the SQL schema to enhance query performance for tasks, including composite indexes for filtering and sorting.
- Added indexes for various task-related entities such as assignees, phases, labels, comments, and attachments to improve data retrieval efficiency.
- Implemented covering indexes to optimize task aggregation and search functionalities, ensuring faster access to frequently queried data.
2025-07-24 10:16:09 +05:30
chamikaJ
95aa2ef8ee refactor: centralize Ant Design imports and remove unused components
- Replaced direct imports from 'antd' with centralized imports from '@/shared/antd-imports' across multiple components for improved maintainability.
- Removed the TawkTo component as it is no longer needed.
- Updated the CustomAvatar and various other components to utilize the new import structure, enhancing code clarity and consistency.
- Introduced the InviteTeamMembersModal component, streamlining the invitation process for team members.
2025-07-23 17:20:42 +05:30
chamikaJ
e3443eedfb feat(localization): add guest role text and invitation message fields for team members
- Introduced new localization strings for guest role descriptions and invitation messages across German, English, and Spanish.
- Added fields for personal messages and email input instructions in the team members settings, enhancing user experience during team member invitations.
2025-07-23 17:20:30 +05:30
chamikaJ
03bd3659e0 feat(guidelines): add general coding, localization, and component naming rules
- Introduced a comprehensive set of general coding guidelines to enhance code readability and maintainability.
- Established a localization rule to prevent hard-coded user-facing text, ensuring all strings are managed through the localization system.
- Defined a naming convention for React components, mandating the use of PascalCase for consistency across the codebase.
- Included examples and enforcement strategies for each rule to facilitate adherence during development and code reviews.
2025-07-23 17:20:14 +05:30
Chamika J
5c327a3a21 Merge pull request #281 from Worklenz/development
Development
2025-07-23 16:07:57 +05:30
Chamika J
123a912e64 Merge pull request #280 from Worklenz/fix/release-v2.1.3
Fix/release v2.1.3
2025-07-23 15:09:27 +05:30
chamikaJ
78516d8d6c feat(analytics-hubspot): modularize analytics and HubSpot integration
- Moved Google Analytics and HubSpot integration scripts to separate modules for better organization and maintainability.
- Implemented an AnalyticsManager class to handle Google Analytics initialization and privacy notice display.
- Created a HubSpotManager class to manage HubSpot widget loading and dark mode theming.
- Updated index.html to reference the new modules, improving code clarity and separation of concerns.
2025-07-23 14:36:50 +05:30
chamikaJ
9946c9a00e fix(upgrade-plans): adjust minimum seat value logic and enhance HubSpot widget dark mode styles
- Updated the minimum seat value logic in the upgrade plans component to allow the current seat count.
- Added CSS overrides for the HubSpot widget to ensure proper display in dark mode, removing unwanted backgrounds and shadows.
2025-07-23 13:24:58 +05:30
chamikaJ
4887383dc4 feat(upgrade-plans): add responsive styles and improve layout for upgrade plans component
- Introduced a new CSS file for responsive design in the upgrade plans component, enhancing mobile usability.
- Updated the upgrade plans component to utilize the new styles, ensuring a better layout on various screen sizes.
- Refactored seat count options logic for improved clarity and functionality.
2025-07-23 13:03:00 +05:30
chamikaJ
a6863d8280 refactor: update code to use centralized Ant Design imports
- Replaced direct import of '@ant-design/icons' with centralized import from '@/shared/antd-imports'
2025-07-23 12:07:48 +05:30
chamikaJ
edf81dbe57 refactor: update Ant Design imports to centralized path
- Replaced direct imports from 'antd' with centralized imports from '@/shared/antd-imports' to align with new import rules and improve maintainability.
2025-07-23 11:05:39 +05:30
chamikaJ
80f5febb51 feat(antd-imports): establish centralized import rules for Ant Design components
- Introduced a new rules file for Ant Design component imports, enforcing the use of centralized imports from `@antd-imports` to enhance tree-shaking, maintainability, and performance.
- Updated various components to replace direct imports from 'antd' and '@ant-design/icons' with the centralized import path.
- Refactored existing components to utilize memoization and callbacks for improved performance and readability.
- Enhanced the billing and configuration components with updated styles and improved user experience.
2025-07-23 10:33:55 +05:30
chamiakJ
a6286eb2b8 feat(migrations): add README for node-pg-migrate and enhance frontend configuration
- Created a README file for database migrations using node-pg-migrate, detailing commands, file format, best practices, and an example migration.
- Added a Vite configuration file for the frontend, including plugin setup, alias resolution, build optimizations, and responsive design adjustments for task list components.
- Updated i18n configuration to preload the 'home' namespace for improved localization.
- Enhanced task list styling with responsive design features for better mobile usability.
2025-07-23 07:46:39 +05:30
Chamika J
ee75aead78 Merge pull request #278 from Worklenz/fix/release-v2.1.3
Fix/release v2.1.3
2025-07-22 17:26:45 +05:30
chamikaJ
e3c002b088 style(task-list): enhance styling for task list components
- Updated the EmptyGroupDropZone component to include top and bottom borders for better visual separation.
- Adjusted the AddTaskRow component by removing the bottom border for a cleaner appearance.
2025-07-22 17:25:37 +05:30
chamikaJ
3beed3dae6 feat(auth): update password requirements and localization for signup
- Updated password label and placeholder to "Password" and "Enter your password" across multiple languages.
- Added password guideline for minimum requirements in English, German, Spanish, Portuguese, Albanian, and Chinese.
- Introduced maximum character requirement for passwords in all supported languages.
- Enhanced password validation messages to improve user experience during signup.
2025-07-22 17:25:25 +05:30
chamikaJ
33aace71c8 refactor(tasks): improve code readability and formatting in tasks-controller-v2
- Refactored import statements for better organization and clarity.
- Enhanced formatting of methods and conditionals for improved readability.
- Updated task filtering methods to maintain consistent formatting and style.
- Added performance logging for deprecated methods to assist in monitoring usage.
- Cleaned up unnecessary comments and improved inline documentation for better understanding.
2025-07-22 17:12:06 +05:30
chamikaJ
354b9422ed feat(tasks): add color_code_dark to task groups and enhance task list display
- Introduced color_code_dark property to task groups for improved theming support.
- Updated task list components to utilize color_code_dark based on the current theme mode.
- Enhanced empty state handling in task list to dynamically create an unmapped group for better user experience.
- Refactored task management slice to support dynamic group creation for unmapped tasks.
2025-07-22 16:08:41 +05:30
chamikaJ
256f1eb3a9 feat(task-list): enhance empty state display in task list component
- Added EmptyListPlaceholder component to visually represent the empty state in the task list.
- Adjusted styling and layout for the empty group drop zone to improve user experience.
- Removed unused progress properties from the task group data structure for cleaner code.
2025-07-22 12:46:09 +05:30
chamikaJ
5f86ba6b13 feat(auth): implement new authentication pages and refactor routes
- Added new authentication pages: LoginPage, SignupPage, ForgotPasswordPage, AuthenticatingPage, LoggingOutPage, and VerifyResetEmailPage.
- Refactored auth routes to use updated component names for better consistency and clarity.
- Enhanced user experience with improved loading states and error handling across authentication processes.
2025-07-22 12:27:05 +05:30
Chamika J
a112d39321 Merge pull request #272 from Worklenz/development
Development
2025-07-15 17:20:59 +05:30
Chamika J
4788294bc4 Merge pull request #271 from Worklenz/release/v2.1.2
Release/v2.1.2
2025-07-15 17:20:25 +05:30
491 changed files with 3780 additions and 1805 deletions

View File

@@ -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 (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`
# 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 `@antd-imports` or `@/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 `@antd-imports`
2. Replace direct '@ant-design/icons' imports with `@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 (
<Button icon={<EditOutlined />} onClick={handleClick}>
Edit Item
</Button>
);
};
```
### Form Implementation
```typescript
import { Form, Input, Select, Button, DatePicker } from '@antd-imports';
const MyForm = () => {
return (
<Form layout="vertical">
<Form.Item label="Name" name="name">
<Input />
</Form.Item>
<Form.Item label="Type" name="type">
<Select options={options} />
</Form.Item>
<Form.Item label="Date" name="date">
<DatePicker />
</Form.Item>
</Form>
);
};
```
## Enforcement
This rule is **MANDATORY** and applies to:
- All new component development
- All code refactoring
- All bug fixes
- All feature implementations
**Violations will result in code review rejection.**
### File Path:
The centralized file is located at: `worklenz-frontend/src/shared/antd-imports.ts`

View File

@@ -0,0 +1,131 @@
# General Coding Guidelines
## Rule Summary
Follow these rules when you write code:
1. **Use Early Returns**
- Prefer early returns and guard clauses to reduce nesting and improve readability, especially for error handling.
2. **Tailwind for Styling**
- Always use Tailwind CSS utility classes for styling HTML elements.
- Avoid writing custom CSS or using inline `style` tags.
3. **Class Tag Syntax**
- Use `class:` directive (e.g., `class:active={isActive}`) instead of the ternary operator in class tags whenever possible.
4. **Descriptive Naming**
- Use clear, descriptive names for variables, functions, and constants.
- Use auxiliary verbs for booleans and state (e.g., `isLoaded`, `hasError`, `shouldRender`).
- Event handler functions should be prefixed with `handle`, e.g., `handleClick` for `onClick`, `handleKeyDown` for `onKeyDown`.
5. **Naming Conventions**
- **Directories:** Use lowercase with dashes (e.g., `components/auth-wizard`).
- **Variables & Functions:** Use `camelCase` (e.g., `userList`, `fetchData`).
- **Types & Interfaces:** Use `PascalCase` (e.g., `User`, `ButtonProps`).
- **Exports:** Favor named exports for components.
- **No Unused Variables:** Remove unused variables and imports.
6. **File Layout**
- Order: exported component → subcomponents → hooks/helpers → static content.
7. **Props & Types**
- Define props with TypeScript `interface` or `type`, not `prop-types`.
- Example:
```ts
interface ButtonProps {
label: string;
onClick?: () => void;
}
export function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
```
8. **Component Declaration**
- Use the `function` keyword for components, not arrow functions.
9. **Hooks Usage**
- Call hooks (e.g., `useState`, `useEffect`) only at the top level of components.
- Extract reusable logic into custom hooks (e.g., `useAuth`, `useFormValidation`).
10. **Memoization & Performance**
- Use `React.memo`, `useCallback`, and `useMemo` where appropriate.
- Avoid inline functions in JSX—pull handlers out or wrap in `useCallback`.
11. **Composition**
- Favor composition (render props, `children`) over inheritance.
12. **Code Splitting**
- Use `React.lazy` + `Suspense` for code splitting.
13. **Refs**
- Use refs only for direct DOM access.
14. **Forms**
- Prefer controlled components for forms.
15. **Error Boundaries**
- Implement an error boundary component for catching render errors.
16. **Effect Cleanup**
- Clean up effects in `useEffect` to prevent memory leaks.
17. **Accessibility**
- Apply appropriate ARIA attributes to interactive elements.
- For example, an `<a>` tag should have `tabindex="0"`, `aria-label`, `onClick`, and `onKeyDown` attributes as appropriate.
## Examples
### ✅ Correct
```tsx
// File: components/user-profile.tsx
interface UserProfileProps {
user: User;
isLoaded: boolean;
hasError: boolean;
}
export function UserProfile({ user, isLoaded, hasError }: UserProfileProps) {
if (!isLoaded) return <div>Loading...</div>;
if (hasError) return <div role="alert">Error loading user.</div>;
const handleClick = useCallback(() => {
// ...
}, [user]);
return (
<button
className="bg-blue-500 text-white"
aria-label="View user profile"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{user.name}
</button>
);
}
```
### ❌ Incorrect
```tsx
// File: components/UserProfile.jsx
function userprofile(props) {
if (props.isLoaded) {
// ...
}
}
return (
<button style={{ color: 'white' }} onClick={() => doSomething()}>
View
</button>
);
```
## Enforcement
- All new code must follow these guidelines.
- Code reviews should reject code that does not comply with these rules.
- Refactor existing code to follow these guidelines when making changes.

View File

@@ -0,0 +1,38 @@
# Localization Rule: No Hard-Coded User-Facing Text
## Rule
- All user-facing text **must** be added to the localization system at `@/locales`.
- **Never** hard-code user-facing strings directly in components, pages, or business logic.
- Use the appropriate i18n or localization utility to fetch and display all text.
- **Always** provide a `defaultValue` when using the `t()` function for translations, e.g., `{t('emailPlaceholder', {defaultValue: 'Enter your email'})}`.
## Rationale
- Ensures the application is fully translatable and accessible to all supported languages.
- Prevents missed strings during translation updates.
- Promotes consistency and maintainability.
- Providing a `defaultValue` ensures a fallback is shown if the translation key is missing.
## Examples
### ✅ Correct
```tsx
import { useTranslation } from 'react-i18next';
const { t } = useTranslation();
return <input placeholder={t('emailPlaceholder', { defaultValue: 'Enter your email' })} />;
```
### ❌ Incorrect
```tsx
return <input placeholder={t('emailPlaceholder')} />;
// or
return <input placeholder="Enter your email" />;
```
## Enforcement
- All new user-facing text **must** be added to the appropriate file in `@/locales`.
- Every use of `t()` **must** include a `defaultValue` for fallback.
- Code reviews should reject any hard-coded user-facing strings or missing `defaultValue` in translations.
- Refactor existing hard-coded text to use the localization system and add `defaultValue` when modifying related code.

View File

@@ -0,0 +1,39 @@
# React Component Naming Rule: PascalCase
## Rule
- All React component names **must** use PascalCase.
- This applies to:
- Component file names (e.g., `MyComponent.tsx`, `UserProfile.jsx`)
- Exported component identifiers (e.g., `export const MyComponent = ...` or `function UserProfile() { ... }`)
## Rationale
- PascalCase is the community standard for React components.
- Ensures consistency and readability across the codebase.
- Prevents confusion between components and regular functions/variables.
## Examples
### ✅ Correct
```tsx
// File: UserProfile.tsx
export function UserProfile() { ... }
// File: TaskList.tsx
const TaskList = () => { ... }
export default TaskList;
```
### ❌ Incorrect
```tsx
// File: userprofile.tsx
export function userprofile() { ... }
// File: task-list.jsx
const task_list = () => { ... }
export default task_list;
```
## Enforcement
- All new React components **must** follow this rule.
- Refactor existing components to PascalCase when modifying or moving them.
- Code reviews should reject non-PascalCase component names.

View File

@@ -0,0 +1,72 @@
# Node-pg-migrate Migrations
This directory contains database migrations managed by node-pg-migrate.
## Migration Commands
- `npm run migrate:create -- migration-name` - Create a new migration file
- `npm run migrate:up` - Run all pending migrations
- `npm run migrate:down` - Rollback the last migration
- `npm run migrate:redo` - Rollback and re-run the last migration
## Migration File Format
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
Each migration file exports two functions:
- `exports.up` - Contains the forward migration logic
- `exports.down` - Contains the rollback logic
## Best Practices
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
2. **Test migrations locally** before deploying to production
3. **Include rollback logic** in the `down` function for all changes
4. **Use descriptive names** for migration files
5. **Keep migrations focused** - one logical change per migration
## Example Migration
```javascript
exports.up = pgm => {
// Create table with IF NOT EXISTS
pgm.createTable('users', {
id: 'id',
name: { type: 'varchar(100)', notNull: true },
created_at: {
type: 'timestamp',
notNull: true,
default: pgm.func('current_timestamp')
}
}, { ifNotExists: true });
// Add index with IF NOT EXISTS
pgm.createIndex('users', 'name', {
name: 'idx_users_name',
ifNotExists: true
});
};
exports.down = pgm => {
// Drop in reverse order
pgm.dropIndex('users', 'name', {
name: 'idx_users_name',
ifExists: true
});
pgm.dropTable('users', { ifExists: true });
};
```
## Migration History
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
## Converting from SQL Migrations
When converting SQL migrations to node-pg-migrate format:
1. Wrap SQL statements in `pgm.sql()` calls
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
3. Always include `IF EXISTS/IF NOT EXISTS` checks
4. Ensure proper rollback logic in the `down` function

View File

@@ -132,3 +132,139 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
ON projects (team_id, name);
-- Performance indexes for optimized tasks queries
-- From migration: 20250115000000-performance-indexes.sql
-- Composite index for main task filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
ON tasks(project_id, archived, parent_task_id)
WHERE archived = FALSE;
-- Index for status joins
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
ON tasks(status_id, project_id)
WHERE archived = FALSE;
-- Index for assignees lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
ON tasks_assignees(task_id, team_member_id);
-- Index for phase lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
ON task_phase(task_id, phase_id);
-- Index for subtask counting
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
ON tasks(parent_task_id, archived)
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
-- Index for labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
ON task_labels(task_id, label_id);
-- Index for comments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
ON task_comments(task_id);
-- Index for attachments count
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
ON task_attachments(task_id);
-- Index for work log aggregation
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
ON task_work_log(task_id);
-- Index for subscribers check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
ON task_subscribers(task_id);
-- Index for dependencies check
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
ON task_dependencies(task_id);
-- Index for timers lookup
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
ON task_timers(task_id, user_id);
-- Index for custom columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
ON cc_column_values(task_id);
-- Index for team member info view optimization
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
ON team_members(team_id, user_id)
WHERE active = TRUE;
-- Index for notification settings
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
ON notification_settings(user_id, team_id);
-- Index for task status categories
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
ON task_statuses(category_id, project_id);
-- Index for project phases
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
ON project_phases(project_id, sort_index);
-- Index for task priorities
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
ON task_priorities(value);
-- Index for team labels
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
ON team_labels(team_id);
-- Advanced performance indexes for task optimization
-- Composite index for task main query optimization (covers most WHERE conditions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
WHERE archived = FALSE;
-- Index for sorting by sort_order with project filter
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
ON tasks(project_id, sort_order)
WHERE archived = FALSE;
-- Index for email_invitations to optimize team_member_info_view
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
ON email_invitations(team_member_id);
-- Covering index for task status with category information
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
ON task_statuses(id, category_id, project_id);
-- Index for task aggregation queries (parent task progress calculation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
ON tasks(parent_task_id, status_id, archived)
WHERE archived = FALSE;
-- Index for project team member filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
ON team_members(team_id, active, user_id)
WHERE active = TRUE;
-- Covering index for tasks with frequently accessed columns
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
WHERE archived = FALSE;
-- Index for task search functionality
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
ON tasks USING gin(to_tsvector('english', name))
WHERE archived = FALSE;
-- Index for date-based filtering (if used)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
ON tasks(project_id, start_date, end_date)
WHERE archived = FALSE;
-- Index for task timers with user filtering
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
ON task_timers(user_id, task_id);
-- Index for sys_task_status_categories lookups
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);

View File

@@ -16,6 +16,7 @@ export interface ITaskGroup {
start_date?: string;
end_date?: string;
color_code: string;
color_code_dark: string;
category_id: string | null;
old_category_id?: string;
todo_progress?: number;

File diff suppressed because it is too large Load Diff

View File

@@ -89,24 +89,24 @@ export const NumbersColorMap: { [x: string]: string } = {
};
export const PriorityColorCodes: { [x: number]: string; } = {
0: "#75c997",
1: "#fbc84c",
2: "#f37070"
0: "#2E8B57",
1: "#DAA520",
2: "#CD5C5C"
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
0: "#46D980",
1: "#FFC227",
2: "#FF4141"
0: "#3CB371",
1: "#B8860B",
2: "#F08080"
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
export const TASK_STATUS_DOING_COLOR = "#70a6f3";
export const TASK_STATUS_DONE_COLOR = "#75c997";
export const TASK_PRIORITY_LOW_COLOR = "#75c997";
export const TASK_PRIORITY_MEDIUM_COLOR = "#fbc84c";
export const TASK_PRIORITY_HIGH_COLOR = "#f37070";
export const TASK_PRIORITY_LOW_COLOR = "#2E8B57";
export const TASK_PRIORITY_MEDIUM_COLOR = "#DAA520";
export const TASK_PRIORITY_HIGH_COLOR = "#CD5C5C";
export const TASK_DUE_COMPLETED_COLOR = "#75c997";
export const TASK_DUE_UPCOMING_COLOR = "#70a6f3";

View File

@@ -57,116 +57,15 @@
<!-- Environment configuration -->
<script src="/env-config.js"></script>
<!-- Optimized Google Analytics with reduced blocking -->
<script>
// Function to initialize Google Analytics asynchronously
function initGoogleAnalytics() {
// Use requestIdleCallback to defer analytics loading
const loadAnalytics = () => {
// Determine which tracking ID to use based on the environment
const isProduction = window.location.hostname === 'app.worklenz.com';
const trackingId = isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG'; // Open source tracking ID
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${trackingId}`;
document.head.appendChild(script);
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', trackingId);
};
// Use requestIdleCallback if available, otherwise setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
setTimeout(loadAnalytics, 1000);
}
}
// Initialize analytics after a delay to not block initial render
initGoogleAnalytics();
// Function to show privacy notice
function showPrivacyNotice() {
const notice = document.createElement('div');
notice.style.cssText = `
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
color: #f5f5f5;
padding: 12px 16px 10px 16px;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
z-index: 1000;
max-width: 320px;
font-family: Inter, sans-serif;
border: 1px solid #333;
font-size: 0.95rem;
`;
notice.innerHTML = `
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
`;
document.body.appendChild(notice);
// Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', function (e) {
e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true');
notice.remove();
});
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', function () {
// Check if we should show the notice
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
// Show notice if not in production and not shown before
if (!isProduction && !noticeShown) {
showPrivacyNotice();
}
});
</script>
<!-- Analytics Module -->
<script src="/js/analytics.js"></script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="./src/index.tsx"></script>
<script type="text/javascript">
// Load HubSpot script asynchronously and only for production
if (window.location.hostname === 'app.worklenz.com') {
// Use requestIdleCallback to defer HubSpot loading
const loadHubSpot = () => {
var hs = document.createElement('script');
hs.type = 'text/javascript';
hs.id = 'hs-script-loader';
hs.async = true;
hs.defer = true;
hs.src = '//js.hs-scripts.com/22348300.js';
document.body.appendChild(hs);
};
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
}
</script>
<!-- HubSpot Integration Module -->
<script src="/js/hubspot.js"></script>
</body>
</html>

View File

@@ -0,0 +1,97 @@
/**
* Google Analytics initialization module
* Handles analytics loading and privacy notices
*/
class AnalyticsManager {
constructor() {
this.isProduction = window.location.hostname === 'app.worklenz.com';
this.trackingId = this.isProduction ? 'G-7KSRKQ1397' : 'G-3LM2HGWEXG';
}
/**
* Initialize Google Analytics asynchronously
*/
init() {
const loadAnalytics = () => {
// Load the Google Analytics script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.trackingId}`;
document.head.appendChild(script);
// Initialize Google Analytics
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', this.trackingId);
};
// Use requestIdleCallback if available, otherwise setTimeout
if ('requestIdleCallback' in window) {
requestIdleCallback(loadAnalytics, { timeout: 2000 });
} else {
setTimeout(loadAnalytics, 1000);
}
}
/**
* Show privacy notice for non-production environments
*/
showPrivacyNotice() {
const notice = document.createElement('div');
notice.style.cssText = `
position: fixed;
bottom: 16px;
right: 16px;
background: #222;
color: #f5f5f5;
padding: 12px 16px 10px 16px;
border-radius: 7px;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
z-index: 1000;
max-width: 320px;
font-family: Inter, sans-serif;
border: 1px solid #333;
font-size: 0.95rem;
`;
notice.innerHTML = `
<div style="margin-bottom: 6px; font-weight: 600; color: #fff; font-size: 1rem;">Analytics Notice</div>
<div style="margin-bottom: 8px; color: #f5f5f5;">This app uses Google Analytics for anonymous usage stats. No personal data is tracked.</div>
<button id="analytics-notice-btn" style="padding: 5px 14px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 0.95rem;">Got it</button>
`;
document.body.appendChild(notice);
// Add event listener to button
const btn = notice.querySelector('#analytics-notice-btn');
btn.addEventListener('click', (e) => {
e.preventDefault();
localStorage.setItem('privacyNoticeShown', 'true');
notice.remove();
});
}
/**
* Check if privacy notice should be shown
*/
checkPrivacyNotice() {
const isProduction =
window.location.hostname === 'worklenz.com' ||
window.location.hostname === 'app.worklenz.com';
const noticeShown = localStorage.getItem('privacyNoticeShown') === 'true';
// Show notice if not in production and not shown before
if (!isProduction && !noticeShown) {
this.showPrivacyNotice();
}
}
}
// Initialize analytics when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
const analytics = new AnalyticsManager();
analytics.init();
analytics.checkPrivacyNotice();
});

View File

@@ -0,0 +1,137 @@
/**
* HubSpot Chat Widget integration module
* Handles widget loading and dark mode theming
*/
class HubSpotManager {
constructor() {
this.isProduction = window.location.hostname === 'app.worklenz.com';
this.scriptId = 'hs-script-loader';
this.scriptSrc = '//js.hs-scripts.com/22348300.js';
this.styleId = 'hubspot-dark-mode-override';
}
/**
* Load HubSpot script with dark mode support
*/
init() {
if (!this.isProduction) return;
const loadHubSpot = () => {
const script = document.createElement('script');
script.type = 'text/javascript';
script.id = this.scriptId;
script.async = true;
script.defer = true;
script.src = this.scriptSrc;
// Configure dark mode after script loads
script.onload = () => this.setupDarkModeSupport();
document.body.appendChild(script);
};
// Use requestIdleCallback for better performance
if ('requestIdleCallback' in window) {
requestIdleCallback(loadHubSpot, { timeout: 3000 });
} else {
setTimeout(loadHubSpot, 2000);
}
}
/**
* Setup dark mode theme switching for HubSpot widget
*/
setupDarkModeSupport() {
const applyTheme = () => {
const isDark = document.documentElement.classList.contains('dark');
// Remove existing theme styles
const existingStyle = document.getElementById(this.styleId);
if (existingStyle) {
existingStyle.remove();
}
if (isDark) {
this.injectDarkModeCSS();
}
};
// Apply initial theme after delay to ensure widget is loaded
setTimeout(applyTheme, 1000);
// Watch for theme changes
const observer = new MutationObserver(applyTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
/**
* Inject CSS for dark mode styling
*/
injectDarkModeCSS() {
const style = document.createElement('style');
style.id = this.styleId;
style.textContent = `
/* HubSpot Chat Widget Dark Mode Override */
#hubspot-conversations-inline-parent,
#hubspot-conversations-iframe-container,
.shadow-2xl.widget-align-right.widget-align-bottom,
[data-test-id="chat-widget"],
[class*="VizExCollapsedChat"],
[class*="VizExExpandedChat"],
iframe[src*="hubspot"] {
filter: invert(1) hue-rotate(180deg) !important;
background: transparent !important;
}
/* Target HubSpot widget container backgrounds */
#hubspot-conversations-inline-parent div,
#hubspot-conversations-iframe-container div,
[data-test-id="chat-widget"] div {
background-color: transparent !important;
}
/* Prevent double inversion of images, avatars, and icons */
#hubspot-conversations-iframe-container img,
#hubspot-conversations-iframe-container [style*="background-image"],
#hubspot-conversations-iframe-container svg,
iframe[src*="hubspot"] img,
iframe[src*="hubspot"] svg,
[data-test-id="chat-widget"] img,
[data-test-id="chat-widget"] svg {
filter: invert(1) hue-rotate(180deg) !important;
}
/* Additional targeting for widget launcher and chat bubble */
div[class*="shadow-2xl"],
div[class*="widget-align"],
div[style*="position: fixed"] {
filter: invert(1) hue-rotate(180deg) !important;
}
`;
document.head.appendChild(style);
}
/**
* Remove HubSpot widget and associated styles
*/
cleanup() {
const script = document.getElementById(this.scriptId);
const style = document.getElementById(this.styleId);
if (script) script.remove();
if (style) style.remove();
}
}
// Initialize HubSpot integration
document.addEventListener('DOMContentLoaded', () => {
const hubspot = new HubSpotManager();
hubspot.init();
// Make available globally for potential cleanup
window.HubSpotManager = hubspot;
});

View File

@@ -7,11 +7,13 @@
"emailLabel": "Email",
"emailPlaceholder": "Shkruani email-in tuaj",
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
"passwordLabel": "Fjalëkalimi",
"passwordPlaceholder": "Krijoni një fjalëkalim",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "Fjalëkalimi nuk i plotëson kërkesat!",
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
"signupSuccessMessage": "Jeni regjistruar me sukses!",

View File

@@ -7,11 +7,13 @@
"emailLabel": "E-Mail",
"emailPlaceholder": "Ihre E-Mail-Adresse eingeben",
"emailRequired": "Bitte geben Sie Ihre E-Mail-Adresse ein!",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Ihr Passwort eingeben",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Bitte geben Sie Ihr Passwort ein!",
"passwordMinCharacterRequired": "Das Passwort muss mindestens 8 Zeichen lang sein!",
"passwordPatternRequired": "Das Passwort erfüllt nicht die Anforderungen!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "Das Passwort entspricht nicht den Anforderungen!",
"strongPasswordPlaceholder": "Ein stärkeres Passwort eingeben",
"passwordValidationAltText": "Das Passwort muss mindestens 8 Zeichen enthalten, mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.",
"signupSuccessMessage": "Sie haben sich erfolgreich registriert!",

View File

@@ -38,10 +38,16 @@
"updateMemberErrorMessage": "Aktualisierung des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.",
"memberText": "Mitglied",
"adminText": "Administrator",
"guestText": "Gast (Nur Lesen)",
"ownerText": "Team-Besitzer",
"addedText": "Hinzugefügt",
"updatedText": "Aktualisiert",
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
"invitationResent": "Einladung erfolgreich erneut gesendet!"
"invitationResent": "Einladung erfolgreich erneut gesendet!",
"emailsStepDescription": "Geben Sie E-Mail-Adressen für Teammitglieder ein, die Sie einladen möchten",
"personalMessageLabel": "Persönliche Nachricht",
"personalMessagePlaceholder": "Fügen Sie eine persönliche Nachricht zu Ihrer Einladung hinzu (optional)",
"optionalFieldLabel": "(Optional)",
"inviteTeamMembersModalTitle": "Teammitglieder einladen"
}

View File

@@ -8,9 +8,11 @@
"emailPlaceholder": "Enter your email",
"emailRequired": "Please enter your Email!",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Please enter your Password!",
"passwordMinCharacterRequired": "Password must be at least 8 characters!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "Password does not meet the requirements!",
"strongPasswordPlaceholder": "Enter a stronger password",
"passwordValidationAltText": "Password must include at least 8 characters with upper and lower case letters, a number, and a symbol.",

View File

@@ -38,10 +38,16 @@
"updateMemberErrorMessage": "Failed to update team member. Please try again.",
"memberText": "Member",
"adminText": "Admin",
"guestText": "Guest (Read-only)",
"ownerText": "Team Owner",
"addedText": "Added",
"updatedText": "Updated",
"noResultFound": "Type an email address and hit enter...",
"jobTitlesFetchError": "Failed to fetch job titles",
"invitationResent": "Invitation resent successfully!"
"invitationResent": "Invitation resent successfully!",
"emailsStepDescription": "Enter email addresses for team members you'd like to invite",
"personalMessageLabel": "Personal Message",
"personalMessagePlaceholder": "Add a personal message to your invitation (optional)",
"optionalFieldLabel": "(Optional)",
"inviteTeamMembersModalTitle": "Invite team members"
}

View File

@@ -7,10 +7,12 @@
"emailLabel": "Correo electrónico",
"emailPlaceholder": "Ingresa tu correo electrónico",
"emailRequired": "¡Por favor ingresa tu correo electrónico!",
"passwordLabel": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "¡Por favor ingresa tu contraseña!",
"passwordMinCharacterRequired": "¡La contraseña debe tener al menos 8 caracteres!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "¡La contraseña no cumple con los requisitos!",
"strongPasswordPlaceholder": "Ingresa una contraseña más segura",
"passwordValidationAltText": "La contraseña debe incluir al menos 8 caracteres con letras mayúsculas y minúsculas, un número y un símbolo.",

View File

@@ -38,10 +38,16 @@
"updateMemberErrorMessage": "Error al actualizar miembro del equipo. Por favor, intente nuevamente.",
"memberText": "Miembro del equipo",
"adminText": "Administrador",
"guestText": "Invitado (Solo lectura)",
"ownerText": "Propietario del equipo",
"addedText": "Agregado",
"updatedText": "Actualizado",
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
"jobTitlesFetchError": "Error al obtener los cargos",
"invitationResent": "¡Invitación reenviada exitosamente!"
"invitationResent": "¡Invitación reenviada exitosamente!",
"emailsStepDescription": "Ingrese las direcciones de correo de los miembros del equipo que desea invitar",
"personalMessageLabel": "Mensaje Personal",
"personalMessagePlaceholder": "Agregue un mensaje personal a su invitación (opcional)",
"optionalFieldLabel": "(Opcional)",
"inviteTeamMembersModalTitle": "Invitar miembros del equipo"
}

View File

@@ -7,11 +7,13 @@
"emailLabel": "Email",
"emailPlaceholder": "Insira seu email",
"emailRequired": "Por favor, insira seu Email!",
"passwordLabel": "Senha",
"passwordPlaceholder": "Insira sua senha",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "Por favor, insira sua Senha!",
"passwordMinCharacterRequired": "Senha deve ter pelo menos 8 caracteres!",
"passwordPatternRequired": "Senha não atende aos requisitos!",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "A senha não atende aos requisitos!",
"strongPasswordPlaceholder": "Insira uma senha mais forte",
"passwordValidationAltText": "Senha deve incluir pelo menos 8 caracteres com letras maiúsculas e minúsculas, um número e um símbolo.",
"signupSuccessMessage": "Você se inscreveu com sucesso!",

View File

@@ -7,10 +7,12 @@
"emailLabel": "电子邮件",
"emailPlaceholder": "输入您的电子邮件",
"emailRequired": "请输入您的电子邮件!",
"passwordLabel": "密码",
"passwordPlaceholder": "输入您的密码",
"passwordLabel": "Password",
"passwordGuideline": "Password must be at least 8 characters, include uppercase and lowercase letters, a number, and a special character.",
"passwordPlaceholder": "Enter your password",
"passwordRequired": "请输入您的密码!",
"passwordMinCharacterRequired": "密码必须至少包含8个字符",
"passwordMaxCharacterRequired": "Password must be at most 32 characters!",
"passwordPatternRequired": "密码不符合要求!",
"strongPasswordPlaceholder": "输入更强的密码",
"passwordValidationAltText": "密码必须至少包含8个字符包括大小写字母、一个数字和一个符号。",

View File

@@ -4,12 +4,12 @@ import { Navigate } from 'react-router-dom';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Lazy load auth page components for better code splitting
const LoginPage = lazy(() => import('@/pages/auth/login-page'));
const SignupPage = lazy(() => import('@/pages/auth/signup-page'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/forgot-password-page'));
const LoggingOutPage = lazy(() => import('@/pages/auth/logging-out'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/authenticating'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/verify-reset-email'));
const LoginPage = lazy(() => import('@/pages/auth/LoginPage'));
const SignupPage = lazy(() => import('@/pages/auth/SignupPage'));
const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage'));
const LoggingOutPage = lazy(() => import('@/pages/auth/LoggingOutPage'));
const AuthenticatingPage = lazy(() => import('@/pages/auth/AuthenticatingPage'));
const VerifyResetEmailPage = lazy(() => import('@/pages/auth/VerifyResetEmailPage'));
const authRoutes = [
{

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { PlusOutlined, UserAddOutlined } from '@/shared/antd-imports';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';

View File

@@ -1,4 +1,4 @@
import { Flex, Typography } from 'antd';
import { Flex, Typography } from '@/shared/antd-imports';
import logo from '@/assets/images/worklenz-light-mode.png';
import logoDark from '@/assets/images/worklenz-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -1,8 +1,6 @@
import React from 'react';
import Tooltip from 'antd/es/tooltip';
import Avatar from 'antd/es/avatar';
import { AvatarNamesMap } from '../shared/constants';
import { Avatar, Tooltip } from '@/shared/antd-imports';
interface CustomAvatarProps {
avatarName: string;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Tooltip } from '@/shared/antd-imports';
import { Label } from '@/types/task-management.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Tooltip } from 'antd';
import { Tooltip } from '@/shared/antd-imports';
import { NumbersColorMap } from '@/shared/constants';
interface CustomNumberLabelProps {

View File

@@ -1,5 +1,5 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input } from 'antd';
import { SearchOutlined } from '@/shared/antd-imports';
import { Input } from '@/shared/antd-imports';
type CustomSearchbarProps = {
placeholderText: string;

View File

@@ -1,6 +1,6 @@
import { Flex, Tooltip, Typography } from 'antd';
import { Flex, Tooltip, Typography } from '@/shared/antd-imports';
import { colors } from '../styles/colors';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { ExclamationCircleOutlined } from '@/shared/antd-imports';
// this custom table title used when the typography font weigh 500 needed
const CustomTableTitle = ({ title, tooltip }: { title: string; tooltip?: string | null }) => {

View File

@@ -1,17 +1,24 @@
import { Empty, Typography } from 'antd';
import { Empty, Typography } from '@/shared/antd-imports';
import React from 'react';
import { useTranslation } from 'react-i18next';
type EmptyListPlaceholderProps = {
imageSrc?: string;
imageHeight?: number;
text: string;
text?: string;
textKey?: string;
i18nNs?: string;
};
const EmptyListPlaceholder = ({
imageSrc = 'https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp',
imageHeight = 60,
text,
textKey,
i18nNs = 'task-list-table',
}: EmptyListPlaceholderProps) => {
const { t } = useTranslation(i18nNs);
const description = textKey ? t(textKey) : text;
return (
<Empty
image={imageSrc}
@@ -22,7 +29,7 @@ const EmptyListPlaceholder = ({
alignItems: 'center',
marginBlockStart: 24,
}}
description={<Typography.Text type="secondary">{text}</Typography.Text>}
description={<Typography.Text type="secondary">{description}</Typography.Text>}
/>
);
};

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Button, Result } from 'antd';
import { Button, Result } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import logger from '@/utils/errorLogger';

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import { PlusOutlined, TagOutlined } from '@ant-design/icons';
import { PlusOutlined, TagOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';

View File

@@ -1,5 +1,5 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Button, Result } from 'antd';
import { Button, Result } from '@/shared/antd-imports';
import CacheCleanup from '@/utils/cache-cleanup';
interface Props {

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localStorageFunctions';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { PushpinFilled, PushpinOutlined } from '@ant-design/icons';
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';

View File

@@ -1,5 +1,5 @@
import { FloatButton, Space, Tooltip } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
import { FloatButton, Space, Tooltip } from '@/shared/antd-imports';
import { FormatPainterOutlined } from '@/shared/antd-imports';
// import LanguageSelector from '../features/i18n/language-selector';
// import ThemeSelector from '../features/theme/ThemeSelector';

View File

@@ -1,50 +0,0 @@
import { useEffect } from 'react';
// Add TypeScript declarations for Tawk_API
declare global {
interface Window {
Tawk_API?: any;
Tawk_LoadStart?: Date;
}
}
interface TawkToProps {
propertyId: string;
widgetId: string;
}
const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
useEffect(() => {
// Initialize tawk.to chat
const s1 = document.createElement('script');
s1.async = true;
s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`;
s1.setAttribute('crossorigin', '*');
const s0 = document.getElementsByTagName('script')[0];
s0.parentNode?.insertBefore(s1, s0);
return () => {
// Clean up when the component unmounts
// Remove the script tag
const tawkScript = document.querySelector(`script[src*="tawk.to/${propertyId}"]`);
if (tawkScript && tawkScript.parentNode) {
tawkScript.parentNode.removeChild(tawkScript);
}
// Remove the tawk.to iframe
const tawkIframe = document.getElementById('tawk-iframe');
if (tawkIframe) {
tawkIframe.remove();
}
// Reset Tawk globals
delete window.Tawk_API;
delete window.Tawk_LoadStart;
};
}, [propertyId, widgetId]);
return null;
};
export default TawkTo;

View File

@@ -1,8 +1,8 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, Button, List, Alert, message, InputRef } from 'antd';
import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@ant-design/icons';
import { Form, Input, Button, List, Alert, message, InputRef } from '@/shared/antd-imports';
import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { Typography } from 'antd';
import { Typography } from '@/shared/antd-imports';
import { setTeamMembers, setTasks } from '@/features/account-setup/account-setup.slice';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/app/store';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, InputRef, Typography } from 'antd';
import { Form, Input, InputRef, Typography } from '@/shared/antd-imports';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';

View File

@@ -3,7 +3,7 @@ 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, Select, Typography } from '@/shared/antd-imports';
import TemplateDrawer from '../common/template-drawer/template-drawer';
import { RootState } from '@/app/store';

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, Button, Typography, List, InputRef } from 'antd';
import { PlusOutlined, DeleteOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { Form, Input, Button, Typography, List, InputRef } from '@/shared/antd-imports';
import { PlusOutlined, DeleteOutlined, CloseCircleOutlined } from '@/shared/antd-imports';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
import { UserAddOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from '@/shared/antd-imports';
import { UserAddOutlined, UsergroupAddOutlined } from '@/shared/antd-imports';
import './add-members-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { AvatarNamesMap } from '@/shared/constants';

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from '@/shared/antd-imports';
import { PlusOutlined, UsergroupAddOutlined } from '@/shared/antd-imports';
import './add-members-dropdown.css';
import { AvatarNamesMap } from '../../shared/constants';
import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -1,11 +1,8 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { fetchStorageInfo } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SUBSCRIPTION_STATUS } from '@/shared/constants';
import { IBillingAccountStorage } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { Card, Progress, Typography } from 'antd/es';
import { Card, Progress, Typography } from '@/shared/antd-imports';
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -2,7 +2,7 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
import { IBillingCharge, IBillingChargesResponse } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { Table, TableProps, Tag } from 'antd';
import { Table, TableProps, Tag } from '@/shared/antd-imports';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -27,19 +27,19 @@ const ChargesTable: React.FC = () => {
const columns: TableProps<IBillingCharge>['columns'] = [
{
title: t('description'),
title: t('description') as string,
key: 'name',
dataIndex: 'name',
},
{
title: t('billingPeriod'),
title: t('billingPeriod') as string,
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
},
},
{
title: t('billStatus'),
title: t('billStatus') as string,
key: 'status',
dataIndex: 'status',
render: (_, record) => {
@@ -55,7 +55,7 @@ const ChargesTable: React.FC = () => {
},
},
{
title: t('perUserValue'),
title: t('perUserValue') as string,
key: 'perUserValue',
dataIndex: 'perUserValue',
render: (_, record) => (
@@ -65,12 +65,12 @@ const ChargesTable: React.FC = () => {
),
},
{
title: t('users'),
title: t('users') as string,
key: 'quantity',
dataIndex: 'quantity',
},
{
title: t('amount'),
title: t('amount') as string,
key: 'amount',
dataIndex: 'amount',
render: (_, record) => (

View File

@@ -2,8 +2,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { ContainerOutlined } from '@ant-design/icons';
import { Button, Table, TableProps, Tag, Tooltip } from 'antd';
import { ContainerOutlined } from '@/shared/antd-imports';
import { Button, Table, TableProps, Tag, Tooltip } from '@/shared/antd-imports';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,7 +1,7 @@
import { Card, Col, Row, Tooltip, Typography } from 'antd';
import React, { useEffect } from 'react';
import { Card, Col, Row, Tooltip } from '@/shared/antd-imports';
import React, { useEffect, useMemo, useCallback } from 'react';
import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons';
import { InfoCircleTwoTone } from '@/shared/antd-imports';
import ChargesTable from './billing-tables/charges-table';
import InvoicesTable from './billing-tables/invoices-table';
@@ -20,7 +20,7 @@ import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
const CurrentBill: React.FC = () => {
const CurrentBill: React.FC = React.memo(() => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -32,70 +32,90 @@ const CurrentBill: React.FC = () => {
dispatch(fetchFreePlanSettings());
}, [dispatch]);
const titleStyle = {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
};
const renderMobileView = () => (
<div>
<Col span={24}>
<CurrentPlanDetails />
</Col>
<Col span={24} style={{ marginTop: '1.5rem' }}>
<AccountStorage themeMode={themeMode} />
</Col>
</div>
const titleStyle = useMemo(
() => ({
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
}),
[themeMode]
);
const renderChargesAndInvoices = () => (
<>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={
<span style={titleStyle}>
<span>{t('charges')}</span>
<Tooltip title={t('tooltip')}>
<InfoCircleTwoTone />
</Tooltip>
</span>
}
style={{ marginTop: '16px' }}
>
<ChargesTable />
</Card>
</div>
const cardStyle = useMemo(() => ({ marginTop: '16px' }), []);
const colStyle = useMemo(() => ({ marginTop: '1.5rem' }), []);
const tabletColStyle = useMemo(() => ({ paddingRight: '10px' }), []);
const tabletColStyleRight = useMemo(() => ({ paddingLeft: '10px' }), []);
<div style={{ marginTop: '1.5rem' }}>
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={{ marginTop: '16px' }}>
<InvoicesTable />
</Card>
const renderMobileView = useCallback(
() => (
<div>
<Col span={24}>
<CurrentPlanDetails />
</Col>
<Col span={24} style={colStyle}>
<AccountStorage themeMode={themeMode} />
</Col>
</div>
</>
),
[colStyle, themeMode]
);
const renderChargesAndInvoices = useCallback(
() => (
<>
<div style={colStyle}>
<Card
title={
<span style={titleStyle}>
<span>{t('charges')}</span>
<Tooltip title={t('tooltip')}>
<InfoCircleTwoTone />
</Tooltip>
</span>
}
style={cardStyle}
>
<ChargesTable />
</Card>
</div>
<div style={colStyle}>
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={cardStyle}>
<InvoicesTable />
</Card>
</div>
</>
),
[colStyle, titleStyle, cardStyle, t]
);
const shouldShowChargesAndInvoices = useMemo(
() => currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE,
[currentSession?.subscription_type]
);
return (
<div style={{ width: '100%' }} className="current-billing">
{isTablet ? (
<Row>
<Col span={16} style={{ paddingRight: '10px' }}>
<Col span={16} style={tabletColStyle}>
<CurrentPlanDetails />
</Col>
<Col span={8} style={{ paddingLeft: '10px' }}>
<Col span={8} style={tabletColStyleRight}>
<AccountStorage themeMode={themeMode} />
</Col>
</Row>
) : (
renderMobileView()
)}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderChargesAndInvoices()}
{shouldShowChargesAndInvoices && renderChargesAndInvoices()}
</div>
);
};
});
CurrentBill.displayName = 'CurrentBill';
export default CurrentBill;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
evt_billing_pause_plan,
@@ -17,10 +17,9 @@ import {
Typography,
Statistic,
Select,
Form,
Row,
Col,
} from 'antd/es';
} from '@/shared/antd-imports';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
@@ -30,7 +29,7 @@ import {
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { WarningTwoTone, PlusOutlined } from '@ant-design/icons';
import { WarningTwoTone, PlusOutlined } from '@/shared/antd-imports';
import { calculateTimeGap } from '@/utils/calculate-time-gap';
import { formatDate } from '@/utils/timeUtils';
import UpgradePlansLKR from '../drawers/upgrade-plans-lkr/upgrade-plans-lkr';
@@ -38,6 +37,21 @@ import UpgradePlans from '../drawers/upgrade-plans/upgrade-plans';
import { ISUBSCRIPTION_TYPE, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { billingApiService } from '@/api/admin-center/billing.api.service';
type SubscriptionAction = 'pause' | 'resume';
type SeatOption = { label: string; value: number | string };
const SEAT_COUNT_LIMIT = '100+';
const BILLING_DELAY_MS = 8000;
const LTD_USER_LIMIT = 50;
const BUTTON_STYLE = {
backgroundColor: '#1890ff',
borderColor: '#1890ff',
};
const STATISTIC_VALUE_STYLE = {
fontSize: '24px',
fontWeight: 'bold' as const,
};
const CurrentPlanDetails = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
@@ -47,7 +61,7 @@ const CurrentPlanDetails = () => {
const [cancellingPlan, setCancellingPlan] = useState(false);
const [addingSeats, setAddingSeats] = useState(false);
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = useState(false);
const [selectedSeatCount, setSelectedSeatCount] = useState<number | string>(5);
const [selectedSeatCount, setSelectedSeatCount] = useState<number | string>(1);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
@@ -55,14 +69,16 @@ const CurrentPlanDetails = () => {
);
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const seatCountOptions: SeatOption[] = useMemo(() => {
const options: SeatOption[] = [
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
].map(value => ({ label: value.toString(), value }));
options.push({ label: SEAT_COUNT_LIMIT, value: SEAT_COUNT_LIMIT });
return options;
}, []);
type SeatOption = { label: string; value: number | string };
const seatCountOptions: SeatOption[] = [
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
].map(value => ({ label: value.toString(), value }));
seatCountOptions.push({ label: '100+', value: '100+' });
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => {
const isResume = action === 'resume';
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
const apiMethod = isResume
@@ -78,21 +94,21 @@ const CurrentPlanDetails = () => {
setLoadingState(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(eventType);
}, 8000);
return; // Exit function to prevent finally block from executing
}, BILLING_DELAY_MS);
return;
}
} catch (error) {
logger.error(`Error ${action}ing subscription`, error);
setLoadingState(false); // Only set to false on error
setLoadingState(false);
}
};
}, [dispatch, trackMixpanelEvent]);
const handleAddMoreSeats = () => {
const handleAddMoreSeats = useCallback(() => {
setIsMoreSeatsModalVisible(true);
};
}, []);
const handlePurchaseMoreSeats = async () => {
if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
const handlePurchaseMoreSeats = useCallback(async () => {
if (selectedSeatCount.toString() === SEAT_COUNT_LIMIT || !billingInfo?.total_seats) return;
try {
setAddingSeats(true);
@@ -108,51 +124,75 @@ const CurrentPlanDetails = () => {
} finally {
setAddingSeats(false);
}
};
}, [selectedSeatCount, billingInfo?.total_seats, dispatch, trackMixpanelEvent]);
const calculateRemainingSeats = () => {
const calculateRemainingSeats = useMemo(() => {
if (billingInfo?.total_seats && billingInfo?.total_used) {
return billingInfo.total_seats - billingInfo.total_used;
}
return 0;
};
}, [billingInfo?.total_seats, billingInfo?.total_used]);
const checkSubscriptionStatus = (allowedStatuses: any[]) => {
// Calculate intelligent default for seat selection based on current usage
const getDefaultSeatCount = useMemo(() => {
const currentUsed = billingInfo?.total_used || 0;
const availableSeats = calculateRemainingSeats;
// If only 1 user and no available seats, suggest 1 additional seat
if (currentUsed === 1 && availableSeats === 0) {
return 1;
}
// If they have some users but running low on seats, suggest enough for current users
if (availableSeats < currentUsed && currentUsed > 0) {
return Math.max(1, currentUsed - availableSeats);
}
// Default fallback
return Math.max(1, Math.min(5, currentUsed || 1));
}, [billingInfo?.total_used, calculateRemainingSeats]);
// Update selected seat count when billing info changes
useEffect(() => {
setSelectedSeatCount(getDefaultSeatCount);
}, [getDefaultSeatCount]);
const checkSubscriptionStatus = useCallback((allowedStatuses: string[]) => {
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
return allowedStatuses.includes(billingInfo.status);
};
}, [billingInfo?.status, billingInfo?.is_ltd_user]);
const shouldShowRedeemButton = () => {
const shouldShowRedeemButton = useMemo(() => {
if (billingInfo?.trial_in_progress) return true;
return billingInfo?.ltd_users ? billingInfo.ltd_users < 50 : false;
};
return billingInfo?.ltd_users ? billingInfo.ltd_users < LTD_USER_LIMIT : false;
}, [billingInfo?.trial_in_progress, billingInfo?.ltd_users]);
const showChangeButton = () => {
const showChangeButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
}, [checkSubscriptionStatus]);
const showPausePlanButton = () => {
const showPausePlanButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
}, [checkSubscriptionStatus]);
const showResumePlanButton = () => {
const showResumePlanButton = useMemo(() => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
};
}, [checkSubscriptionStatus]);
const shouldShowAddSeats = () => {
const shouldShowAddSeats = useMemo(() => {
if (!billingInfo) return false;
return (
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
);
};
}, [billingInfo]);
const renderExtra = () => {
const renderExtra = useCallback(() => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Space>
{showPausePlanButton() && (
{showPausePlanButton && (
<Button
type="link"
danger
@@ -163,7 +203,7 @@ const CurrentPlanDetails = () => {
</Button>
)}
{showResumePlanButton() && (
{showResumePlanButton && (
<Button
type="primary"
loading={cancellingPlan}
@@ -179,7 +219,7 @@ const CurrentPlanDetails = () => {
</Button>
)}
{showChangeButton() && (
{showChangeButton && (
<Button
type="primary"
loading={pausingPlan || cancellingPlan}
@@ -190,9 +230,19 @@ const CurrentPlanDetails = () => {
)}
</Space>
);
};
}, [
billingInfo,
showPausePlanButton,
showResumePlanButton,
showChangeButton,
pausingPlan,
cancellingPlan,
handleSubscriptionAction,
dispatch,
t,
]);
const renderLtdDetails = () => {
const renderLtdDetails = useCallback(() => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Flex vertical>
@@ -200,41 +250,41 @@ const CurrentPlanDetails = () => {
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
</Flex>
);
};
}, [billingInfo, t]);
const renderTrialDetails = () => {
const checkIfTrialExpired = () => {
if (!billingInfo?.trial_expire_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const trialExpireDate = new Date(billingInfo.trial_expire_date);
trialExpireDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
return today > trialExpireDate;
};
const checkIfTrialExpired = useCallback(() => {
if (!billingInfo?.trial_expire_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const trialExpireDate = new Date(billingInfo.trial_expire_date);
trialExpireDate.setHours(0, 0, 0, 0);
return today > trialExpireDate;
}, [billingInfo?.trial_expire_date]);
const getExpirationMessage = (expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const getExpirationMessage = useCallback((expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0);
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
return t('expirestomorrow', 'tomorrow');
} else if (expDate < today) {
const diffTime = Math.abs(today.getTime() - expDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
} else {
return calculateTimeGap(expireDate);
}
};
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
return t('expirestomorrow', 'tomorrow');
} else if (expDate < today) {
const diffTime = Math.abs(today.getTime() - expDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
} else {
return calculateTimeGap(expireDate);
}
}, [t]);
const renderTrialDetails = useCallback(() => {
const isExpired = checkIfTrialExpired();
const trialExpireDate = billingInfo?.trial_expire_date || '';
@@ -257,9 +307,9 @@ const CurrentPlanDetails = () => {
</Tooltip>
</Flex>
);
};
}, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
const renderFreePlan = () => (
const renderFreePlan = useCallback(() => (
<Flex vertical>
<Typography.Text strong>{t('freePlan')}</Typography.Text>
<Typography.Text>
@@ -271,9 +321,9 @@ const CurrentPlanDetails = () => {
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
</Typography.Text>
</Flex>
);
), [freePlanSettings, t]);
const renderPaddleSubscriptionInfo = () => {
const renderPaddleSubscriptionInfo = useCallback(() => {
return (
<Flex vertical>
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
@@ -287,14 +337,14 @@ const CurrentPlanDetails = () => {
</Typography.Text>
</Flex>
{shouldShowAddSeats() && billingInfo?.total_seats && (
{shouldShowAddSeats && billingInfo?.total_seats && (
<div style={{ marginTop: '16px' }}>
<Row gutter={16} align="middle">
<Col span={6}>
<Statistic
title={t('totalSeats')}
title={t('totalSeats') as string}
value={billingInfo.total_seats}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
valueStyle={STATISTIC_VALUE_STYLE}
/>
</Col>
<Col span={8}>
@@ -302,16 +352,16 @@ const CurrentPlanDetails = () => {
type="primary"
icon={<PlusOutlined />}
onClick={handleAddMoreSeats}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
style={BUTTON_STYLE}
>
{t('addMoreSeats')}
</Button>
</Col>
<Col span={6}>
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
title={t('availableSeats') as string}
value={calculateRemainingSeats}
valueStyle={STATISTIC_VALUE_STYLE}
/>
</Col>
</Row>
@@ -319,17 +369,17 @@ const CurrentPlanDetails = () => {
)}
</Flex>
);
};
}, [billingInfo, shouldShowAddSeats, handleAddMoreSeats, calculateRemainingSeats, t]);
const renderCreditSubscriptionInfo = () => {
const renderCreditSubscriptionInfo = useCallback(() => {
return (
<Flex vertical>
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
</Flex>
);
};
}, [t]);
const renderCustomSubscriptionInfo = () => {
const renderCustomSubscriptionInfo = useCallback(() => {
return (
<Flex vertical>
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
@@ -340,7 +390,36 @@ const CurrentPlanDetails = () => {
</Typography.Text>
</Flex>
);
};
}, [billingInfo?.valid_till_date, t]);
const renderSubscriptionContent = useCallback(() => {
if (!billingInfo) return null;
switch (billingInfo.subscription_type) {
case ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL:
return renderLtdDetails();
case ISUBSCRIPTION_TYPE.TRIAL:
return renderTrialDetails();
case ISUBSCRIPTION_TYPE.FREE:
return renderFreePlan();
case ISUBSCRIPTION_TYPE.PADDLE:
return renderPaddleSubscriptionInfo();
case ISUBSCRIPTION_TYPE.CREDIT:
return renderCreditSubscriptionInfo();
case ISUBSCRIPTION_TYPE.CUSTOM:
return renderCustomSubscriptionInfo();
default:
return null;
}
}, [
billingInfo,
renderLtdDetails,
renderTrialDetails,
renderFreePlan,
renderPaddleSubscriptionInfo,
renderCreditSubscriptionInfo,
renderCustomSubscriptionInfo,
]);
return (
<Card
@@ -361,19 +440,10 @@ const CurrentPlanDetails = () => {
>
<Flex vertical>
<div style={{ marginBottom: '14px' }}>
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT &&
renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM &&
renderCustomSubscriptionInfo()}
{renderSubscriptionContent()}
</div>
{shouldShowRedeemButton() && (
{shouldShowRedeemButton && (
<>
<Button
type="link"
@@ -408,17 +478,28 @@ const CurrentPlanDetails = () => {
<Typography.Paragraph
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
>
{t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
{billingInfo?.total_used === 1
? t('purchaseSeatsTextSingle', "Add more seats to invite team members to your workspace.")
: t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")
}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
seats: billingInfo?.total_seats,
})}
{billingInfo?.total_used === 1 && (
<span style={{ color: '#666', marginLeft: '8px' }}>
({t('singleUserNote', 'Currently used by 1 team member')})
</span>
)}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
{t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
{billingInfo?.total_used === 1
? t('selectSeatsTextSingle', 'Select how many additional seats you need for new team members.')
: t('selectSeatsText', 'Please select the number of additional seats to purchase.')
}
</Typography.Paragraph>
<div style={{ marginBottom: '24px' }}>
@@ -430,18 +511,18 @@ const CurrentPlanDetails = () => {
options={seatCountOptions}
style={{ width: '300px' }}
/>
</div>
<Flex justify="end">
{selectedSeatCount.toString() !== '100+' ? (
{selectedSeatCount.toString() !== SEAT_COUNT_LIMIT ? (
<Button
type="primary"
loading={addingSeats}
onClick={handlePurchaseMoreSeats}
style={{
minWidth: '100px',
backgroundColor: '#1890ff',
borderColor: '#1890ff',
...BUTTON_STYLE,
borderRadius: '2px',
}}
>

View File

@@ -1,4 +1,4 @@
import { Button, Drawer, Form, Input, notification, Typography } from 'antd';
import { Button, Drawer, Form, Input, notification, Typography } from '@/shared/antd-imports';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';

View File

@@ -1,7 +1,7 @@
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from 'antd';
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from '@/shared/antd-imports';
import React, { useState } from 'react';
import './upgrade-plans-lkr.css';
import { CheckCircleFilled } from '@ant-design/icons';
import { CheckCircleFilled } from '@/shared/antd-imports';
import { RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';

View File

@@ -0,0 +1,44 @@
.upgrade-plans-responsive {
padding: 24px;
}
.upgrade-plans-row-responsive {
width: 100%;
flex-wrap: wrap;
}
@media (max-width: 900px) {
.upgrade-plans-row-responsive .ant-col {
flex: 0 0 100%;
max-width: 100%;
margin-bottom: 16px;
}
.upgrade-plans-row-responsive .ant-card {
width: 100%;
min-width: 0;
}
}
@media (max-width: 600px) {
.upgrade-plans-responsive {
padding: 8px;
}
.upgrade-plans-row-responsive .ant-col {
padding: 0 !important;
margin-bottom: 12px;
}
.upgrade-plans-row-responsive .ant-card {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.upgrade-plans-responsive .ant-typography,
.upgrade-plans-responsive .ant-btn,
.upgrade-plans-responsive .ant-form-item {
width: 100%;
text-align: center;
}
.upgrade-plans-responsive .ant-btn {
margin-top: 12px;
}
}

View File

@@ -11,7 +11,7 @@ import {
Tooltip,
Typography,
message,
} from 'antd/es';
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
@@ -22,7 +22,7 @@ import {
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IPaddlePlans, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { CheckCircleFilled, InfoCircleOutlined } from '@ant-design/icons';
import { CheckCircleFilled, InfoCircleOutlined } from '@/shared/antd-imports';
import { useAuthService } from '@/hooks/useAuth';
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -30,6 +30,7 @@ import { billingApiService } from '@/api/admin-center/billing.api.service';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
import './upgrade-plans.css';
// Extend Window interface to include Paddle
declare global {
@@ -65,20 +66,24 @@ const UpgradePlans = () => {
const [paddleError, setPaddleError] = useState<string | null>(null);
const populateSeatCountOptions = (currentSeats: number) => {
if (!currentSeats) return [];
if (typeof currentSeats !== 'number') return [];
const step = 5;
const maxSeats = 90;
const minValue = Math.min(currentSeats + 1);
const rangeStart = Math.ceil(minValue / step) * step;
const range = Array.from(
{ length: Math.floor((maxSeats - rangeStart) / step) + 1 },
(_, i) => rangeStart + i * step
);
const minValue = currentSeats;
const options: { value: number; disabled: boolean }[] = [];
return currentSeats < step
? [...Array.from({ length: rangeStart - minValue }, (_, i) => minValue + i), ...range]
: range;
// Always show 1-5, but disable if less than minValue
for (let i = 1; i <= 5; i++) {
options.push({ value: i, disabled: i < minValue });
}
// Show all multiples of 5 from 10 to maxSeats
for (let i = 10; i <= maxSeats; i += step) {
options.push({ value: i, disabled: i < minValue });
}
return options;
};
const fetchPricingPlans = async () => {
@@ -339,24 +344,25 @@ const UpgradePlans = () => {
}, []);
return (
<div>
<div className="upgrade-plans-responsive">
<Flex justify="center" align="center">
<Typography.Title level={2}>
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
? t('selectPlan')
: t('changeSubscriptionPlan')}
? t('selectPlan', 'Select Plan')
: t('changeSubscriptionPlan', 'Change Subscription Plan')}
</Typography.Title>
</Flex>
<Flex justify="center" align="center">
<Form form={form}>
<Form.Item name="seatCount" label={t('noOfSeats')}>
<Form.Item name="seatCount" label={t('noOfSeats', 'Number of Seats')}>
<Select
style={{ width: 100 }}
value={selectedSeatCount}
options={seatCountOptions.map(option => ({
value: option,
text: option.toString(),
value: option.value,
label: option.value.toString(),
disabled: option.disabled,
}))}
onChange={setSelectedSeatCount}
/>
@@ -365,31 +371,31 @@ const UpgradePlans = () => {
</Flex>
<Flex>
<Row className="w-full">
<Row className="w-full upgrade-plans-row-responsive">
{/* Free Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
title={<span style={cardStyles.title}>{t('freePlan', 'Free Plan')}</span>}
onClick={() => setSelectedCard(paddlePlans.FREE)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ 0.00</Typography.Title>
<Typography.Text>{t('freeForever')}</Typography.Text>
<Typography.Text>{t('freeForever', 'Free Forever')}</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
{t('bestForPersonalUse')}
{t('bestForPersonalUse', 'Best for Personal Use')}
</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(`${plans.free_tier_storage} ${t('storage')}`)}
{renderFeature(`${plans.projects_limit} ${t('projects')}`)}
{renderFeature(`${plans.team_member_limit} ${t('teamMembers')}`)}
{renderFeature(`${plans.free_tier_storage} ${t('storage', 'Storage')}`)}
{renderFeature(`${plans.projects_limit} ${t('projects', 'Projects')}`)}
{renderFeature(`${plans.team_member_limit} ${t('teamMembers', 'Team Members')}`)}
</div>
</Card>
</Col>
@@ -401,9 +407,9 @@ const UpgradePlans = () => {
hoverable
title={
<span style={cardStyles.title}>
{t('annualPlan')}{' '}
{t('annualPlan', 'Annual Plan')}{' '}
<Tag color="volcano" style={{ lineHeight: '21px' }}>
{t('tag')}
{t('tag', 'Popular')}
</Tag>
</span>
}
@@ -429,16 +435,16 @@ const UpgradePlans = () => {
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedAnnually')}</Typography.Text>
<Typography.Text>{t('billedAnnually', 'Billed Annually')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList} className="mt-4">
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
{renderFeature(t('startupText01', 'Unlimited Projects'))}
{renderFeature(t('startupText02', 'Unlimited Team Members'))}
{renderFeature(t('startupText03', 'Unlimited Storage'))}
{renderFeature(t('startupText04', 'Priority Support'))}
{renderFeature(t('startupText05', 'Advanced Analytics'))}
</div>
</Card>
</Col>
@@ -448,7 +454,7 @@ const UpgradePlans = () => {
<Card
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
title={<span style={cardStyles.title}>{t('monthlyPlan', 'Monthly Plan')}</span>}
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
>
<div style={cardStyles.priceContainer}>
@@ -469,16 +475,16 @@ const UpgradePlans = () => {
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedMonthly')}</Typography.Text>
<Typography.Text>{t('billedMonthly', 'Billed Monthly')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
{renderFeature(t('startupText01', 'Unlimited Projects'))}
{renderFeature(t('startupText02', 'Unlimited Team Members'))}
{renderFeature(t('startupText03', 'Unlimited Storage'))}
{renderFeature(t('startupText04', 'Priority Support'))}
{renderFeature(t('startupText05', 'Advanced Analytics'))}
</div>
</Card>
</Col>
@@ -509,8 +515,8 @@ const UpgradePlans = () => {
disabled={billingInfo?.plan_id === plans.annual_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('annualPlan') })
: t('continueWith', { plan: t('annualPlan') })}
? t('changeToPlan', 'Change to {{plan}}', { plan: t('annualPlan', 'Annual Plan') })
: t('continueWith', 'Continue with {{plan}}', { plan: t('annualPlan', 'Annual Plan') })}
</Button>
)}
{selectedPlan === paddlePlans.MONTHLY && (
@@ -522,8 +528,8 @@ const UpgradePlans = () => {
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('monthlyPlan') })
: t('continueWith', { plan: t('monthlyPlan') })}
? t('changeToPlan', 'Change to {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })
: t('continueWith', 'Continue with {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })}
</Button>
)}
</Row>

View File

@@ -1,5 +1,5 @@
import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd';
import React, { useEffect, useState } from 'react';
import { Button, Card, Col, Divider, Form, Input, Row, Select } from '@/shared/antd-imports';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { RootState } from '../../../app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IBillingConfigurationCountry } from '@/types/admin-center/country.types';
@@ -7,14 +7,15 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
import { IBillingConfiguration } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
const Configuration: React.FC = () => {
const Configuration: React.FC = React.memo(() => {
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [countries, setCountries] = useState<IBillingConfigurationCountry[]>([]);
const [configuration, setConfiguration] = useState<IBillingConfiguration>();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const fetchCountries = async () => {
const fetchCountries = useCallback(async () => {
try {
const res = await adminCenterApiService.getCountries();
if (res.done) {
@@ -23,61 +24,85 @@ const Configuration: React.FC = () => {
} catch (error) {
logger.error('Error fetching countries:', error);
}
};
}, []);
const fetchConfiguration = async () => {
const fetchConfiguration = useCallback(async () => {
const res = await adminCenterApiService.getBillingConfiguration();
if (res.done) {
setConfiguration(res.body);
form.setFieldsValue(res.body);
}
};
}, [form]);
useEffect(() => {
fetchCountries();
fetchConfiguration();
}, []);
}, [fetchCountries, fetchConfiguration]);
const handleSave = async (values: any) => {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
fetchConfiguration();
const handleSave = useCallback(
async (values: any) => {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
fetchConfiguration();
}
} catch (error) {
logger.error('Error updating configuration:', error);
} finally {
setLoading(false);
}
} catch (error) {
logger.error('Error updating configuration:', error);
} finally {
setLoading(false);
}
};
},
[fetchConfiguration]
);
const countryOptions = countries.map(country => ({
label: country.name,
value: country.id,
}));
const countryOptions = useMemo(
() =>
countries.map(country => ({
label: country.name,
value: country.id,
})),
[countries]
);
const titleStyle = useMemo(
() => ({
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
}),
[themeMode]
);
const dividerTitleStyle = useMemo(
() => ({
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 600,
fontSize: '16px',
display: 'flex',
gap: '4px',
}),
[themeMode]
);
const cardStyle = useMemo(() => ({ marginTop: '16px' }), []);
const colStyle = useMemo(() => ({ padding: '0 12px', height: '86px' }), []);
const dividerStyle = useMemo(() => ({ margin: '16px 0' }), []);
const buttonColStyle = useMemo(() => ({ paddingLeft: '12px' }), []);
const handlePhoneInput = useCallback((e: React.FormEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
input.value = input.value.replace(/[^0-9]/g, '');
}, []);
return (
<div>
<Card
title={
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Billing Details
</span>
}
style={{ marginTop: '16px' }}
>
<Card title={<span style={titleStyle}>Billing Details</span>} style={cardStyle}>
<Form form={form} initialValues={configuration} onFinish={handleSave}>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Row gutter={[0, 0]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item
name="name"
label="Name"
@@ -88,10 +113,10 @@ const Configuration: React.FC = () => {
},
]}
>
<Input placeholder="Name" />
<Input placeholder="Name" disabled />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item
name="email"
label="Email Address"
@@ -102,10 +127,10 @@ const Configuration: React.FC = () => {
},
]}
>
<Input placeholder="Name" disabled />
<Input placeholder="Email Address" disabled />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item
name="phone"
label="Contact Number"
@@ -117,58 +142,34 @@ const Configuration: React.FC = () => {
},
]}
>
<Input
placeholder="Phone Number"
maxLength={10}
onInput={e => {
const input = e.target as HTMLInputElement; // Type assertion to access 'value'
input.value = input.value.replace(/[^0-9]/g, ''); // Restrict non-numeric input
}}
/>
<Input placeholder="Phone Number" maxLength={10} onInput={handlePhoneInput} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 600,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Company Details
</span>
<Divider orientation="left" style={{ ...dividerStyle, fontSize: '14px' }}>
<span style={dividerTitleStyle}>Company Details</span>
</Divider>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Row gutter={[0, 0]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="company_name" label="Company Name" layout="vertical">
<Input placeholder="Company Name" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="address_line_1" label="Address Line 01" layout="vertical">
<Input placeholder="Address Line 01" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="address_line_2" label="Address Line 02" layout="vertical">
<Input placeholder="Address Line 02" />
</Form.Item>
</Col>
</Row>
<Row>
<Col
span={8}
style={{
padding: '0 12px',
height: '86px',
scrollbarColor: 'red',
}}
>
<Row gutter={[0, 0]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="country" label="Country" layout="vertical">
<Select
dropdownStyle={{ maxHeight: 256, overflow: 'auto' }}
@@ -181,28 +182,28 @@ const Configuration: React.FC = () => {
/>
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="city" label="City" layout="vertical">
<Input placeholder="City" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="state" label="State" layout="vertical">
<Input placeholder="State" />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Row gutter={[0, 0]}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
<Form.Item name="postal_code" label="Postal Code" layout="vertical">
<Input placeholder="Postal Code" />
</Form.Item>
</Col>
</Row>
<Row>
<Col style={{ paddingLeft: '12px' }}>
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={{ ...buttonColStyle, marginTop: 8 }}>
<Form.Item>
<Button type="primary" htmlType="submit">
<Button type="primary" htmlType="submit" loading={loading} block>
Save
</Button>
</Form.Item>
@@ -212,6 +213,8 @@ const Configuration: React.FC = () => {
</Card>
</div>
);
};
});
Configuration.displayName = 'Configuration';
export default Configuration;

View File

@@ -1,4 +1,4 @@
import { Table, TableProps, Typography } from 'antd';
import { Table, TableProps, Typography } from '@/shared/antd-imports';
import React, { useMemo } from 'react';
import { IOrganizationAdmin } from '@/types/admin-center/admin-center.types';

View File

@@ -1,9 +1,7 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { EnterOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Button, Tooltip, Typography } from 'antd';
import TextArea from 'antd/es/input/TextArea';
import Paragraph from 'antd/es/typography/Paragraph';
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
import { Card, Button, Tooltip, Typography, TextArea } from '@/shared/antd-imports';
import { TFunction } from 'i18next';
import { useState, useEffect } from 'react';

View File

@@ -1,8 +1,8 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { MailOutlined, PhoneOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Tooltip, Input, Button, Typography, InputRef } from 'antd';
import { MailOutlined, PhoneOutlined, EditOutlined } from '@/shared/antd-imports';
import { Card, Tooltip, Input, Button, Typography, InputRef } from '@/shared/antd-imports';
import { TFunction } from 'i18next';
import { useEffect, useRef, useState } from 'react';

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState } from 'react';
import { Button, Drawer, Form, Input, InputRef, Typography } from 'antd';
import { Button, Drawer, Form, Input, InputRef, Typography } from '@/shared/antd-imports';
import { fetchTeams } from '@features/teams/teamSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';

View File

@@ -11,7 +11,7 @@ import {
TableProps,
Typography,
Tooltip,
} from 'antd';
} from '@/shared/antd-imports';
import React, { useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import './settings-drawer.css';

View File

@@ -5,8 +5,8 @@ import { toggleSettingDrawer, deleteTeam, fetchTeams } from '@/features/teams/te
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { SettingOutlined, DeleteOutlined } from '@ant-design/icons';
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
import { SettingOutlined, DeleteOutlined } from '@/shared/antd-imports';
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
import { TFunction } from 'i18next';
import { useState } from 'react';
import { useMediaQuery } from 'react-responsive';

View File

@@ -1,4 +1,4 @@
import { Avatar, Tooltip } from 'antd';
import { Avatar, Tooltip } from '@/shared/antd-imports';
import React, { useCallback, useMemo } from 'react';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';

View File

@@ -1,31 +1,32 @@
import { InputRef } from 'antd/es/input';
import Card from 'antd/es/card';
import Checkbox from 'antd/es/checkbox';
import Divider from 'antd/es/divider';
import Dropdown from 'antd/es/dropdown';
import Empty from 'antd/es/empty';
import Flex from 'antd/es/flex';
import Input from 'antd/es/input';
import List from 'antd/es/list';
import Typography from 'antd/es/typography';
import Button from 'antd/es/button';
import { useMemo, useRef, useState } from 'react';
import {
InputRef,
PlusOutlined,
UsergroupAddOutlined,
Card,
Flex,
Input,
List,
Typography,
Checkbox,
Divider,
Button,
Empty,
Dropdown,
CheckboxChangeEvent,
} from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
import { colors } from '../../../styles/colors';
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { toggleProjectMemberDrawer } from '@features/projects/singleProject/members/projectMembersSlice';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { sortByBooleanField, sortBySelection, sortTeamMembers } from '@/utils/sort-team-members';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
interface BoardAssigneeSelectorProps {
task: IProjectTask;

View File

@@ -1,9 +1,9 @@
import { Badge, Card, Dropdown, Flex, Menu, MenuProps } from 'antd';
import { Badge, Card, Dropdown, Flex, Menu, MenuProps } from '@/shared/antd-imports';
import React from 'react';
import { TaskStatusType } from '../../../types/task.types';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { RetweetOutlined, RightOutlined } from '@/shared/antd-imports';
import './ChangeCategoryDropdown.css';
import { updateStatusCategory } from '../../../features/projects/status/StatusSlice';
import { useTranslation } from 'react-i18next';

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from '@/shared/antd-imports';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from '@/shared/antd-imports';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from '@/shared/antd-imports';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from '@/shared/antd-imports';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';

View File

@@ -1,4 +1,4 @@
import { Button, Flex } from 'antd';
import { Button, Flex } from '@/shared/antd-imports';
import AddMembersDropdown from '@/components/add-members-dropdown/add-members-dropdown';
import Avatars from '../avatars/avatars';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef } from 'react';
import { DatePicker, Button, Flex } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import { DatePicker, Button, Flex } from '@/shared/antd-imports';
import { CalendarOutlined } from '@/shared/antd-imports';
import dayjs, { Dayjs } from 'dayjs';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from '@/shared/antd-imports';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Col, DatePicker, Divider, Flex, Row, Tooltip, Typography } from 'antd';
import { Avatar, Col, DatePicker, Divider, Flex, Row, Tooltip, Typography } from '@/shared/antd-imports';
import StatusDropdown from '../../taskListCommon/statusDropdown/StatusDropdown';
import dayjs, { Dayjs } from 'dayjs';
import { useTranslation } from 'react-i18next';

View File

@@ -9,7 +9,7 @@ import {
Dropdown,
MenuProps,
Button,
} from 'antd';
} from '@/shared/antd-imports';
import {
DoubleRightOutlined,
PauseOutlined,
@@ -20,7 +20,7 @@ import {
ForkOutlined,
CaretRightFilled,
CaretDownFilled,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import './TaskCard.css';
import dayjs, { Dayjs } from 'dayjs';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';

View File

@@ -1,10 +1,10 @@
import { Flex, Typography } from 'antd';
import { Flex, Typography } from '@/shared/antd-imports';
import './priority-section.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useState, useEffect, useMemo } from 'react';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@/shared/antd-imports';
type PrioritySectionProps = {
task: IProjectTask;

View File

@@ -1,4 +1,4 @@
import { Avatar, Button, DatePicker, Input, InputRef } from 'antd';
import { Avatar, Button, DatePicker, Input, InputRef } from '@/shared/antd-imports';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';
import dayjs, { Dayjs } from 'dayjs';

View File

@@ -1,4 +1,4 @@
import { Avatar, Button, DatePicker, Input, InputRef } from 'antd';
import { Avatar, Button, DatePicker, Input, InputRef } from '@/shared/antd-imports';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';
import dayjs, { Dayjs } from 'dayjs';

View File

@@ -1,4 +1,4 @@
import { Calendar } from 'antd';
import { Calendar } from '@/shared/antd-imports';
import React, { useEffect } from 'react';
import type { Dayjs } from 'dayjs';
import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react';
import { Spin } from 'antd';
import { Spin } from '@/shared/antd-imports';
// Lazy load Chart.js components
const LazyBarChart = lazy(() =>

View File

@@ -1,5 +1,5 @@
import React, { Suspense } from 'react';
import { Spin } from 'antd';
import { Spin } from '@/shared/antd-imports';
// Lazy load chart components to reduce initial bundle size
const LazyBar = React.lazy(() =>

View File

@@ -0,0 +1,280 @@
import { Button, Flex, Input, message, Modal, Select, Space, Typography, List, Avatar } from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
toggleInviteMemberDrawer,
triggerTeamMembersRefresh,
} from '../../../features/settings/member/memberSlice';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
import { DeleteOutlined, UserOutlined } from '@ant-design/icons';
interface MemberEntry {
email: string;
access: 'member' | 'admin' | 'guest';
}
const InviteTeamMembersModal = () => {
const [newMembers, setNewMembers] = useState<MemberEntry[]>([]);
const [emailInput, setEmailInput] = useState('');
const [loading, setLoading] = useState(false);
const { t } = useTranslation('settings/team-members');
const isModalOpen = useAppSelector(state => state.memberReducer.isInviteMemberDrawerOpen);
const dispatch = useAppDispatch();
useEffect(() => {
if (isModalOpen) {
// Reset state when modal opens
setNewMembers([]);
setEmailInput('');
// Focus on email input when modal opens
setTimeout(() => {
const emailInput = document.querySelector('input[type="text"]');
if (emailInput) {
(emailInput as HTMLElement).focus();
}
}, 100);
}
}, [isModalOpen]);
const handleFormSubmit = async () => {
try {
setLoading(true);
if (newMembers.length === 0) {
message.error('Please add at least one member');
return;
}
// Send invitations for each member
const promises = newMembers.map(member => {
const body: ITeamMemberCreateRequest = {
emails: [member.email],
is_admin: member.access === 'admin',
is_guest: member.access === 'guest',
};
return teamMembersApiService.createTeamMember(body);
});
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
if (successful > 0) {
message.success(`${successful} invitation(s) sent successfully`);
setNewMembers([]);
setEmailInput('');
dispatch(triggerTeamMembersRefresh());
dispatch(toggleInviteMemberDrawer());
}
const failed = results.length - successful;
if (failed > 0) {
message.error(`${failed} invitation(s) failed`);
}
} catch (error) {
message.error(t('createMemberErrorMessage'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
setNewMembers([]);
setEmailInput('');
dispatch(toggleInviteMemberDrawer());
};
const handleEmailKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
const trimmedEmail = emailInput.trim();
// Don't show error for empty input, just ignore
if (!trimmedEmail) {
return;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmedEmail)) {
message.error('Please enter a valid email address');
return;
}
// Check if email already exists
if (newMembers.find(m => m.email === trimmedEmail)) {
message.warning('Email already added');
return;
}
// Add new member
setNewMembers([...newMembers, { email: trimmedEmail, access: 'member' }]);
setEmailInput('');
}
};
const updateMemberAccess = (index: number, access: 'member' | 'admin' | 'guest') => {
const updated = [...newMembers];
updated[index].access = access;
setNewMembers(updated);
};
const removeMember = (index: number) => {
setNewMembers(newMembers.filter((_, i) => i !== index));
};
const accessOptions = useMemo(() => [
{ value: 'member', label: t('memberText') },
{ value: 'admin', label: t('adminText') },
{ value: 'guest', label: t('guestText') },
], [t]);
const renderContent = () => (
<div>
<div style={{ marginBottom: 16 }}>
<Input
placeholder="Enter email address and press Enter to add"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyPress={handleEmailKeyPress}
size="middle"
autoFocus
style={{
borderRadius: 8,
fontSize: 14
}}
/>
<Typography.Text type="secondary" style={{ fontSize: 12, marginTop: 6, display: 'block', fontStyle: 'italic' }}>
Press Enter to add Multiple emails can be added
</Typography.Text>
</div>
{newMembers.length > 0 && (
<div style={{ marginBottom: 20 }}>
<Typography.Text type="secondary" style={{ fontSize: 13, marginBottom: 12, display: 'block', fontWeight: 500 }}>
Members to invite ({newMembers.length})
</Typography.Text>
<div style={{
background: 'rgba(0, 0, 0, 0.02)',
borderRadius: 8,
padding: '8px 12px',
border: '1px solid rgba(0, 0, 0, 0.06)'
}}>
<List
size="small"
dataSource={newMembers}
split={false}
renderItem={(member, index) => (
<List.Item
style={{
padding: '8px 0',
borderRadius: 6,
marginBottom: index < newMembers.length - 1 ? 4 : 0
}}
actions={[
<Select
size="small"
value={member.access}
onChange={(value) => updateMemberAccess(index, value)}
options={accessOptions}
style={{ width: 90 }}
variant="outlined"
/>,
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => removeMember(index)}
size="small"
danger
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
/>
]}
>
<List.Item.Meta
avatar={
<Avatar size={32} style={{
backgroundColor: '#1677ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<UserOutlined style={{ fontSize: 14 }} />
</Avatar>
}
title={
<span style={{
fontSize: 14,
fontWeight: 500,
color: 'rgba(0, 0, 0, 0.88)'
}}>
{member.email}
</span>
}
description={
<span style={{
fontSize: 12,
color: '#52c41a',
fontWeight: 500
}}>
Ready to invite
</span>
}
/>
</List.Item>
)}
/>
</div>
</div>
)}
</div>
);
return (
<Modal
title={
<Typography.Text strong style={{ fontSize: 18 }}>
{t('inviteTeamMembersModalTitle')}
</Typography.Text>
}
open={isModalOpen}
onCancel={handleClose}
width={520}
destroyOnClose
bodyStyle={{ padding: '16px 20px' }}
footer={
<Flex justify="end">
<Space>
<Button onClick={handleClose}>
Cancel
</Button>
<Button
type="primary"
onClick={handleFormSubmit}
loading={loading}
disabled={newMembers.length === 0}
>
Send
</Button>
</Space>
</Flex>
}
>
{renderContent()}
</Modal>
);
};
export default memo(InviteTeamMembersModal);

View File

@@ -0,0 +1 @@
export { default } from './InviteTeamMembersModal';

View File

@@ -1,4 +1,4 @@
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { PlusOutlined, UserAddOutlined } from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';

View File

@@ -4,7 +4,7 @@ import Icon, {
ClockCircleOutlined,
CloseCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
const iconMap = {
'clock-circle': ClockCircleOutlined,

View File

@@ -1,5 +1,5 @@
import { AvatarNamesMap } from '@/shared/constants';
import { Avatar, Flex, Space } from 'antd';
import { Avatar, Flex, Space } from '@/shared/antd-imports';
interface SingleAvatarProps {
avatarUrl?: string;

View File

@@ -1,4 +1,4 @@
import type { MenuProps } from 'antd';
import type { MenuProps } from '@/shared/antd-imports';
import {
Empty,
List,
@@ -10,8 +10,7 @@ import {
Image,
Input,
Flex,
Button,
} from 'antd';
} from '@/shared/antd-imports';
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
@@ -23,7 +22,7 @@ import {
IWorklenzTemplate,
} from '@/types/project-templates/project-templates.types';
import './template-drawer.css';
import { SearchOutlined } from '@ant-design/icons';
import { SearchOutlined } from '@/shared/antd-imports';
import logger from '@/utils/errorLogger';
const { Title, Text } = Typography;

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Tooltip, TooltipProps } from 'antd';
import { Tooltip, TooltipProps } from '@/shared/antd-imports';
interface TooltipWrapperProps extends Omit<TooltipProps, 'children'> {
children: React.ReactElement;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Card, Spin, Empty } from 'antd';
import { Card, Spin, Empty } from '@/shared/antd-imports';
import {
DndContext,
DragOverlay,

View File

@@ -1,26 +1,32 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { Card, Empty } from '@/shared/antd-imports';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '@/app/store';
import '../EnhancedKanbanBoard.css';
import '../EnhancedKanbanGroup.css';
import '../EnhancedKanbanTaskCard.css';
import ImprovedTaskFilters from '../../task-management/improved-task-filters';
import Card from 'antd/es/card';
import Spin from 'antd/es/spin';
import Empty from 'antd/es/empty';
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import KanbanGroup from './KanbanGroup';
import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import {
reorderGroups,
reorderEnhancedKanbanGroups,
reorderTasks,
reorderEnhancedKanbanTasks,
fetchEnhancedKanbanLabels,
fetchEnhancedKanbanGroups,
fetchEnhancedKanbanTaskAssignees,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import KanbanGroup from './KanbanGroup';
import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection';
import ImprovedTaskFilters from '../../task-management/improved-task-filters';
import { SocketEvents } from '@/shared/socket-events';
import '../EnhancedKanbanBoard.css';
import '../EnhancedKanbanGroup.css';
import '../EnhancedKanbanTaskCard.css';
const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => {
const dispatch = useDispatch();

View File

@@ -1,9 +1,9 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Button, Flex } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Flex } from '@/shared/antd-imports';
import { PlusOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { DownOutlined } from '@ant-design/icons';
import { DownOutlined } from '@/shared/antd-imports';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';

View File

@@ -1,4 +1,4 @@
import { Flex, Input, InputRef } from 'antd';
import { Flex, Input, InputRef } from '@/shared/antd-imports';
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@@ -1,5 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import { Button, Flex, Input, InputRef } from 'antd';
import { Button, Flex, Input, InputRef } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { nanoid } from '@reduxjs/toolkit';
import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -12,7 +12,7 @@ import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard';
import VirtualizedTaskList from './VirtualizedTaskList';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanGroup.css';
import { Badge, Flex, InputRef, MenuProps, Popconfirm } from 'antd';
import { Badge, Flex, InputRef, MenuProps, Popconfirm } from '@/shared/antd-imports';
import { themeWiseColor } from '@/utils/themeWiseColor';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
@@ -25,11 +25,11 @@ import {
MoreOutlined,
} from '@ant-design/icons/lib/icons';
import { colors } from '@/styles/colors';
import { Input } from 'antd';
import { Tooltip } from 'antd';
import { Typography } from 'antd';
import { Dropdown } from 'antd';
import { Button } from 'antd';
import { Input } from '@/shared/antd-imports';
import { Tooltip } from '@/shared/antd-imports';
import { Typography } from '@/shared/antd-imports';
import { Dropdown } from '@/shared/antd-imports';
import { Button } from '@/shared/antd-imports';
import { PlusOutlined } from '@ant-design/icons/lib/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';

View File

@@ -1,37 +1,38 @@
import React, { useCallback, useMemo, useState } from 'react';
import {
ForkOutlined,
CaretDownFilled,
CaretRightFilled,
Tag,
Flex,
Tooltip,
Progress,
Typography,
Button,
Divider,
List,
Skeleton,
PlusOutlined,
Dayjs,
dayjs,
} from '@/shared/antd-imports';
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanTaskCard.css';
import Flex from 'antd/es/flex';
import Tag from 'antd/es/tag';
import Tooltip from 'antd/es/tooltip';
import Progress from 'antd/es/progress';
import Button from 'antd/es/button';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
import PrioritySection from '../board/taskCard/priority-section/priority-section';
import Typography from 'antd/es/typography';
import CustomDueDatePicker from '../board/custom-due-date-picker';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { ForkOutlined } from '@ant-design/icons';
import { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
import {
fetchBoardSubTasks,
toggleTaskExpansion,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { Divider } from 'antd';
import { List } from 'antd';
import { Skeleton } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { themeWiseColor } from '@/utils/themeWiseColor';
import './EnhancedKanbanTaskCard.css';
import LazyAssigneeSelectorWrapper from '../task-management/lazy-assignee-selector';
import CustomDueDatePicker from '../board/custom-due-date-picker';
import BoardSubTaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-sub-task-card';
import BoardCreateSubtaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-create-sub-task-card';
import { useTranslation } from 'react-i18next';
import EnhancedKanbanCreateSubtaskCard from './EnhancedKanbanCreateSubtaskCard';
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
import AvatarGroup from '@/components/AvatarGroup';
interface EnhancedKanbanTaskCardProps {

View File

@@ -1,4 +1,4 @@
import { Badge, Flex, Select } from 'antd';
import { Badge, Flex, Select } from '@/shared/antd-imports';
import './home-tasks-status-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';

View File

@@ -1,6 +1,6 @@
import { useSocket } from '@/socket/socketContext';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { DatePicker } from 'antd';
import { DatePicker } from '@/shared/antd-imports';
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import { SocketEvents } from '@/shared/socket-events';

View File

@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Button, Typography } from 'antd';
import { PlusOutlined, MenuOutlined } from '@ant-design/icons';
import { Button, Typography } from '@/shared/antd-imports';
import { PlusOutlined, MenuOutlined } from '@/shared/antd-imports';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IGroupBy } from '@/features/tasks/tasks.slice';
import KanbanTaskCard from './kanbanTaskCard';

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Avatar, Tag, Progress, Typography, Button, Tooltip, Space } from 'antd';
import { Avatar, Tag, Progress, Typography, Button, Tooltip, Space } from '@/shared/antd-imports';
import {
HolderOutlined,
MessageOutlined,
PaperClipOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { IGroupBy } from '@/features/tasks/tasks.slice';
import { useTranslation } from 'react-i18next';

View File

@@ -17,18 +17,13 @@ import {
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { Card, Spin, Empty, Flex } from 'antd';
import { Card, Spin, Empty } from '@/shared/antd-imports';
import { RootState } from '@/app/store';
import { IGroupBy, setGroup, fetchTaskGroups, reorderTasks } from '@/features/tasks/tasks.slice';
import { fetchTaskGroups, reorderTasks } from '@/features/tasks/tasks.slice';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { AppDispatch } from '@/app/store';
import BoardSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-section-card';
import BoardCreateSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card';
import { useAuthService } from '@/hooks/useAuth';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import BoardViewTaskCard from '@/pages/projects/projectView/board/board-section/board-task-card/board-view-task-card';
import TaskGroup from '../task-management/TaskGroup';
import TaskRow from '../task-management/TaskRow';
import KanbanGroup from './kanbanGroup';
import KanbanTaskCard from './kanbanTaskCard';
import SortableKanbanGroup from './SortableKanbanGroup';

View File

@@ -1,4 +1,4 @@
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from 'antd';
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from '@/shared/antd-imports';
import { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';

View File

@@ -1,5 +1,5 @@
import { BellOutlined } from '@ant-design/icons';
import { Badge, Button, Tooltip } from 'antd';
import { BellOutlined } from '@/shared/antd-imports';
import { Badge, Button, Tooltip } from '@/shared/antd-imports';
import { toggleDrawer } from '@features/navbar/notificationSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';

View File

@@ -1,6 +1,6 @@
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@ant-design/icons';
import { Button, Tag, Typography, theme } from 'antd';
import { BankOutlined } from '@/shared/antd-imports';
import { Button, Tag, Typography, theme } from '@/shared/antd-imports';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import { fromNow } from '@/utils/dateUtils';

View File

@@ -1,5 +1,5 @@
import { Button, Typography, Tag } from 'antd';
import { BankOutlined } from '@ant-design/icons';
import { Button, Typography, Tag } from '@/shared/antd-imports';
import { BankOutlined } from '@/shared/antd-imports';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';

Some files were not shown because too many files have changed in this diff Show More