Compare commits
56 Commits
chore/adde
...
chore/adde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeec5b2b84 | ||
|
|
8380b354cc | ||
|
|
2aaf0fc19a | ||
|
|
65745e368f | ||
|
|
cabd05e0da | ||
|
|
6e71a91d6c | ||
|
|
7dc3dedda5 | ||
|
|
944acf99db | ||
|
|
a9d0244ca2 | ||
|
|
b688f8e114 | ||
|
|
e7e9cfce8c | ||
|
|
27605b4d68 | ||
|
|
ff4b0ed315 | ||
|
|
fe7c15ced1 | ||
|
|
15ff69a031 | ||
|
|
070c643105 | ||
|
|
980af8bd4f | ||
|
|
1931856d31 | ||
|
|
fe3fb5e627 | ||
|
|
ef47df804f | ||
|
|
7ea05d2982 | ||
|
|
daa65465dd | ||
|
|
de26417247 | ||
|
|
69b2fe1a90 | ||
|
|
5c327a3a21 | ||
|
|
123a912e64 | ||
|
|
78516d8d6c | ||
|
|
9946c9a00e | ||
|
|
4887383dc4 | ||
|
|
a6863d8280 | ||
|
|
edf81dbe57 | ||
|
|
300d4763f5 | ||
|
|
80f5febb51 | ||
|
|
aaaac09212 | ||
|
|
c4400d178f | ||
|
|
a6286eb2b8 | ||
|
|
ee75aead78 | ||
|
|
da791e2cb7 | ||
|
|
3373dccc58 | ||
|
|
06da0d20b9 | ||
|
|
5addcee0b2 | ||
|
|
3419d7e81d | ||
|
|
78d960bf01 | ||
|
|
8dc3133814 | ||
|
|
1709fad733 | ||
|
|
7f71e8952b | ||
|
|
22d2023e2a | ||
|
|
fa08463e65 | ||
|
|
7226932247 | ||
|
|
6adf40f5a6 | ||
|
|
f03f6e6f5d | ||
|
|
d7416ff793 | ||
|
|
d89247eb02 | ||
|
|
5318f95037 | ||
|
|
c80b00ec76 | ||
|
|
f48476478a |
@@ -4,7 +4,11 @@
|
|||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(npm run build:*)",
|
"Bash(npm run build:*)",
|
||||||
"Bash(npm run type-check:*)",
|
"Bash(npm run type-check:*)",
|
||||||
"Bash(npm run:*)"
|
"Bash(npm run:*)",
|
||||||
|
"Bash(move:*)",
|
||||||
|
"Bash(mv:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(rm:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
237
.cursor/rules/antd-components.mdc
Normal file
237
.cursor/rules/antd-components.mdc
Normal 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`
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"config-file": "migrate.json"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
-- Migration: Add survey tables for account setup questionnaire
|
||||||
|
-- Date: 2025-07-24
|
||||||
|
-- Description: Creates tables to store survey questions and user responses for account setup flow
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Create surveys table to define different types of surveys
|
||||||
|
CREATE TABLE IF NOT EXISTS surveys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback'
|
||||||
|
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create survey_questions table to store individual questions
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
question_key VARCHAR(100) NOT NULL, -- Used for localization keys
|
||||||
|
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
|
||||||
|
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
options JSONB, -- For choice questions, store options as JSON array
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create survey_responses table to track user responses to surveys
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_responses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
started_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create survey_answers table to store individual question answers
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_answers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
answer_text TEXT,
|
||||||
|
answer_json JSONB, -- For multiple choice answers stored as array
|
||||||
|
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add performance indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
|
||||||
|
|
||||||
|
-- Add constraints
|
||||||
|
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
|
||||||
|
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
|
||||||
|
|
||||||
|
-- Add unique constraint to prevent duplicate responses per user per survey
|
||||||
|
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
||||||
|
|
||||||
|
-- Add unique constraint to prevent duplicate answers per question per response
|
||||||
|
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
||||||
|
|
||||||
|
-- Insert the default account setup survey
|
||||||
|
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||||
|
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Get the survey ID for inserting questions
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
survey_uuid UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||||
|
|
||||||
|
-- Insert survey questions
|
||||||
|
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||||
|
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||||
|
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||||
|
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||||
|
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||||
|
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
/* eslint-disable camelcase */
|
|
||||||
|
|
||||||
exports.shorthands = undefined;
|
|
||||||
|
|
||||||
exports.up = pgm => {
|
|
||||||
// Composite index for main task filtering
|
|
||||||
pgm.sql(`
|
|
||||||
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
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
|
||||||
ON tasks(status_id, project_id)
|
|
||||||
WHERE archived = FALSE
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for assignees lookup
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
|
||||||
ON tasks_assignees(task_id, team_member_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for phase lookup
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
|
||||||
ON task_phase(task_id, phase_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for subtask counting
|
|
||||||
pgm.sql(`
|
|
||||||
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
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
|
||||||
ON task_labels(task_id, label_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for comments count
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
|
||||||
ON task_comments(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for attachments count
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
|
||||||
ON task_attachments(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for work log aggregation
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
|
||||||
ON task_work_log(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for subscribers check
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
|
||||||
ON task_subscribers(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for dependencies check
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
|
||||||
ON task_dependencies(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Additional performance indexes
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_related
|
|
||||||
ON task_dependencies(related_task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for custom column values
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
|
||||||
ON cc_column_values(task_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for project members lookup
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_members_team_project
|
|
||||||
ON project_members(team_member_id, project_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for sorting
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort
|
|
||||||
ON tasks(project_id, sort_order)
|
|
||||||
WHERE archived = FALSE
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for roadmap sorting
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_roadmap_sort
|
|
||||||
ON tasks(project_id, roadmap_sort_order)
|
|
||||||
WHERE archived = FALSE
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for user lookup in team members
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_user_team
|
|
||||||
ON team_members(user_id, team_id)
|
|
||||||
WHERE active = TRUE
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for task statuses lookup
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_project_category
|
|
||||||
ON task_statuses(project_id, category_id)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Index for task priorities lookup
|
|
||||||
pgm.sql(`
|
|
||||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_priority
|
|
||||||
ON tasks(priority_id)
|
|
||||||
WHERE archived = FALSE
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = pgm => {
|
|
||||||
// Drop indexes in reverse order
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_priority');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_statuses_project_category');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_team_members_user_team');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_roadmap_sort');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_sort');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_project_members_team_project');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_cc_column_values_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_dependencies_related');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_dependencies_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_subscribers_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_work_log_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_attachments_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_comments_task');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_labels_task_label');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_parent_archived');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_task_phase_task_phase');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_assignees_task_member');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_status_project');
|
|
||||||
pgm.sql('DROP INDEX IF EXISTS idx_tasks_project_archived_parent');
|
|
||||||
};
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
/* eslint-disable camelcase */
|
|
||||||
|
|
||||||
exports.shorthands = undefined;
|
|
||||||
|
|
||||||
exports.up = pgm => {
|
|
||||||
// Replace the optimized sort functions to avoid CTE usage in UPDATE statements
|
|
||||||
pgm.createFunction(
|
|
||||||
'handle_task_list_sort_between_groups_optimized',
|
|
||||||
[
|
|
||||||
{ name: '_from_index', type: 'integer' },
|
|
||||||
{ name: '_to_index', type: 'integer' },
|
|
||||||
{ name: '_task_id', type: 'uuid' },
|
|
||||||
{ name: '_project_id', type: 'uuid' },
|
|
||||||
{ name: '_batch_size', type: 'integer', default: 100 }
|
|
||||||
],
|
|
||||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
|
||||||
`
|
|
||||||
DECLARE
|
|
||||||
_offset INT := 0;
|
|
||||||
_affected_rows INT;
|
|
||||||
BEGIN
|
|
||||||
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
|
|
||||||
IF (_to_index = -1)
|
|
||||||
THEN
|
|
||||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
|
||||||
IF _to_index > _from_index
|
|
||||||
THEN
|
|
||||||
LOOP
|
|
||||||
UPDATE tasks
|
|
||||||
SET sort_order = sort_order - 1
|
|
||||||
WHERE project_id = _project_id
|
|
||||||
AND sort_order > _from_index
|
|
||||||
AND sort_order < _to_index
|
|
||||||
AND sort_order > _offset
|
|
||||||
AND sort_order <= _offset + _batch_size;
|
|
||||||
|
|
||||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
|
||||||
EXIT WHEN _affected_rows = 0;
|
|
||||||
_offset := _offset + _batch_size;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF _to_index < _from_index
|
|
||||||
THEN
|
|
||||||
_offset := 0;
|
|
||||||
LOOP
|
|
||||||
UPDATE tasks
|
|
||||||
SET sort_order = sort_order + 1
|
|
||||||
WHERE project_id = _project_id
|
|
||||||
AND sort_order > _to_index
|
|
||||||
AND sort_order < _from_index
|
|
||||||
AND sort_order > _offset
|
|
||||||
AND sort_order <= _offset + _batch_size;
|
|
||||||
|
|
||||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
|
||||||
EXIT WHEN _affected_rows = 0;
|
|
||||||
_offset := _offset + _batch_size;
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Replace the second optimized sort function
|
|
||||||
pgm.createFunction(
|
|
||||||
'handle_task_list_sort_inside_group_optimized',
|
|
||||||
[
|
|
||||||
{ name: '_from_index', type: 'integer' },
|
|
||||||
{ name: '_to_index', type: 'integer' },
|
|
||||||
{ name: '_task_id', type: 'uuid' },
|
|
||||||
{ name: '_project_id', type: 'uuid' },
|
|
||||||
{ name: '_batch_size', type: 'integer', default: 100 }
|
|
||||||
],
|
|
||||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
|
||||||
`
|
|
||||||
DECLARE
|
|
||||||
_offset INT := 0;
|
|
||||||
_affected_rows INT;
|
|
||||||
BEGIN
|
|
||||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
|
|
||||||
IF _to_index > _from_index
|
|
||||||
THEN
|
|
||||||
LOOP
|
|
||||||
UPDATE tasks
|
|
||||||
SET sort_order = sort_order - 1
|
|
||||||
WHERE project_id = _project_id
|
|
||||||
AND sort_order > _from_index
|
|
||||||
AND sort_order <= _to_index
|
|
||||||
AND sort_order > _offset
|
|
||||||
AND sort_order <= _offset + _batch_size;
|
|
||||||
|
|
||||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
|
||||||
EXIT WHEN _affected_rows = 0;
|
|
||||||
_offset := _offset + _batch_size;
|
|
||||||
END LOOP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF _to_index < _from_index
|
|
||||||
THEN
|
|
||||||
_offset := 0;
|
|
||||||
LOOP
|
|
||||||
UPDATE tasks
|
|
||||||
SET sort_order = sort_order + 1
|
|
||||||
WHERE project_id = _project_id
|
|
||||||
AND sort_order >= _to_index
|
|
||||||
AND sort_order < _from_index
|
|
||||||
AND sort_order > _offset
|
|
||||||
AND sort_order <= _offset + _batch_size;
|
|
||||||
|
|
||||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
|
||||||
EXIT WHEN _affected_rows = 0;
|
|
||||||
_offset := _offset + _batch_size;
|
|
||||||
END LOOP;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add simple bulk update function as alternative
|
|
||||||
pgm.createFunction(
|
|
||||||
'update_task_sort_orders_bulk',
|
|
||||||
[{ name: '_updates', type: 'json' }],
|
|
||||||
{ returns: 'void', language: 'plpgsql', replace: true },
|
|
||||||
`
|
|
||||||
DECLARE
|
|
||||||
_update_record RECORD;
|
|
||||||
BEGIN
|
|
||||||
-- Simple approach: update each task's sort_order from the provided array
|
|
||||||
FOR _update_record IN
|
|
||||||
SELECT
|
|
||||||
(item->>'task_id')::uuid as task_id,
|
|
||||||
(item->>'sort_order')::int as sort_order,
|
|
||||||
(item->>'status_id')::uuid as status_id,
|
|
||||||
(item->>'priority_id')::uuid as priority_id,
|
|
||||||
(item->>'phase_id')::uuid as phase_id
|
|
||||||
FROM json_array_elements(_updates) as item
|
|
||||||
LOOP
|
|
||||||
UPDATE tasks
|
|
||||||
SET
|
|
||||||
sort_order = _update_record.sort_order,
|
|
||||||
status_id = COALESCE(_update_record.status_id, status_id),
|
|
||||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
|
||||||
WHERE id = _update_record.task_id;
|
|
||||||
|
|
||||||
-- Handle phase updates separately since it's in a different table
|
|
||||||
IF _update_record.phase_id IS NOT NULL THEN
|
|
||||||
INSERT INTO task_phase (task_id, phase_id)
|
|
||||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
|
||||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
|
||||||
END IF;
|
|
||||||
END LOOP;
|
|
||||||
END
|
|
||||||
`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = pgm => {
|
|
||||||
// Drop the functions if needed to rollback
|
|
||||||
pgm.dropFunction('update_task_sort_orders_bulk', [{ name: '_updates', type: 'json' }], { ifExists: true });
|
|
||||||
pgm.dropFunction('handle_task_list_sort_inside_group_optimized', [
|
|
||||||
{ name: '_from_index', type: 'integer' },
|
|
||||||
{ name: '_to_index', type: 'integer' },
|
|
||||||
{ name: '_task_id', type: 'uuid' },
|
|
||||||
{ name: '_project_id', type: 'uuid' },
|
|
||||||
{ name: '_batch_size', type: 'integer' }
|
|
||||||
], { ifExists: true });
|
|
||||||
pgm.dropFunction('handle_task_list_sort_between_groups_optimized', [
|
|
||||||
{ name: '_from_index', type: 'integer' },
|
|
||||||
{ name: '_to_index', type: 'integer' },
|
|
||||||
{ name: '_task_id', type: 'uuid' },
|
|
||||||
{ name: '_project_id', type: 'uuid' },
|
|
||||||
{ name: '_batch_size', type: 'integer' }
|
|
||||||
], { ifExists: true });
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
/* eslint-disable camelcase */
|
|
||||||
|
|
||||||
exports.shorthands = undefined;
|
|
||||||
|
|
||||||
exports.up = pgm => {
|
|
||||||
// Add manual progress fields to tasks table
|
|
||||||
pgm.addColumns('tasks', {
|
|
||||||
manual_progress: {
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
notNull: false
|
|
||||||
},
|
|
||||||
progress_value: {
|
|
||||||
type: 'integer',
|
|
||||||
default: null,
|
|
||||||
notNull: false
|
|
||||||
},
|
|
||||||
weight: {
|
|
||||||
type: 'integer',
|
|
||||||
default: null,
|
|
||||||
notNull: false
|
|
||||||
}
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
// Update function to consider manual progress
|
|
||||||
pgm.createFunction(
|
|
||||||
'get_task_complete_ratio',
|
|
||||||
[{ name: '_task_id', type: 'uuid' }],
|
|
||||||
{ returns: 'json', language: 'plpgsql', replace: true },
|
|
||||||
`
|
|
||||||
DECLARE
|
|
||||||
_parent_task_done FLOAT = 0;
|
|
||||||
_sub_tasks_done FLOAT = 0;
|
|
||||||
_sub_tasks_count FLOAT = 0;
|
|
||||||
_total_completed FLOAT = 0;
|
|
||||||
_total_tasks FLOAT = 0;
|
|
||||||
_ratio FLOAT = 0;
|
|
||||||
_is_manual BOOLEAN = FALSE;
|
|
||||||
_manual_value INTEGER = NULL;
|
|
||||||
BEGIN
|
|
||||||
-- Check if manual progress is set
|
|
||||||
SELECT manual_progress, progress_value
|
|
||||||
FROM tasks
|
|
||||||
WHERE id = _task_id
|
|
||||||
INTO _is_manual, _manual_value;
|
|
||||||
|
|
||||||
-- If manual progress is enabled and has a value, use it directly
|
|
||||||
IF _is_manual IS TRUE AND _manual_value IS NOT NULL THEN
|
|
||||||
RETURN JSON_BUILD_OBJECT(
|
|
||||||
'ratio', _manual_value,
|
|
||||||
'total_completed', 0,
|
|
||||||
'total_tasks', 0,
|
|
||||||
'is_manual', TRUE
|
|
||||||
);
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Otherwise calculate automatically as before
|
|
||||||
SELECT (CASE
|
|
||||||
WHEN EXISTS(SELECT 1
|
|
||||||
FROM tasks_with_status_view
|
|
||||||
WHERE tasks_with_status_view.task_id = _task_id
|
|
||||||
AND is_done IS TRUE) THEN 1
|
|
||||||
ELSE 0 END)
|
|
||||||
INTO _parent_task_done;
|
|
||||||
SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id AND archived IS FALSE INTO _sub_tasks_count;
|
|
||||||
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM tasks_with_status_view
|
|
||||||
WHERE parent_task_id = _task_id
|
|
||||||
AND is_done IS TRUE
|
|
||||||
INTO _sub_tasks_done;
|
|
||||||
|
|
||||||
_total_completed = _parent_task_done + _sub_tasks_done;
|
|
||||||
_total_tasks = _sub_tasks_count; -- +1 for the parent task
|
|
||||||
|
|
||||||
IF _total_tasks > 0 THEN
|
|
||||||
_ratio = (_total_completed / _total_tasks) * 100;
|
|
||||||
ELSE
|
|
||||||
_ratio = _parent_task_done * 100;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN JSON_BUILD_OBJECT(
|
|
||||||
'ratio', _ratio,
|
|
||||||
'total_completed', _total_completed,
|
|
||||||
'total_tasks', _total_tasks,
|
|
||||||
'is_manual', FALSE
|
|
||||||
);
|
|
||||||
END
|
|
||||||
`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = pgm => {
|
|
||||||
// Drop the function first (it depends on the columns)
|
|
||||||
pgm.dropFunction('get_task_complete_ratio', [{ name: '_task_id', type: 'uuid' }], { ifExists: true });
|
|
||||||
|
|
||||||
// Remove the added columns
|
|
||||||
pgm.dropColumns('tasks', ['manual_progress', 'progress_value', 'weight'], { ifExists: true });
|
|
||||||
};
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/* eslint-disable camelcase */
|
|
||||||
|
|
||||||
exports.shorthands = undefined;
|
|
||||||
|
|
||||||
exports.up = pgm => {
|
|
||||||
// Add new sort order columns for different grouping types
|
|
||||||
pgm.addColumns('tasks', {
|
|
||||||
status_sort_order: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 0,
|
|
||||||
notNull: false
|
|
||||||
},
|
|
||||||
priority_sort_order: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 0,
|
|
||||||
notNull: false
|
|
||||||
},
|
|
||||||
phase_sort_order: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 0,
|
|
||||||
notNull: false
|
|
||||||
},
|
|
||||||
member_sort_order: {
|
|
||||||
type: 'integer',
|
|
||||||
default: 0,
|
|
||||||
notNull: false
|
|
||||||
}
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
// Initialize new columns with current sort_order values
|
|
||||||
pgm.sql(`
|
|
||||||
UPDATE tasks SET
|
|
||||||
status_sort_order = sort_order,
|
|
||||||
priority_sort_order = sort_order,
|
|
||||||
phase_sort_order = sort_order,
|
|
||||||
member_sort_order = sort_order
|
|
||||||
WHERE status_sort_order = 0
|
|
||||||
OR priority_sort_order = 0
|
|
||||||
OR phase_sort_order = 0
|
|
||||||
OR member_sort_order = 0
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add constraints to ensure non-negative values
|
|
||||||
pgm.addConstraint('tasks', 'tasks_status_sort_order_check', {
|
|
||||||
check: 'status_sort_order >= 0'
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
pgm.addConstraint('tasks', 'tasks_priority_sort_order_check', {
|
|
||||||
check: 'priority_sort_order >= 0'
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
pgm.addConstraint('tasks', 'tasks_phase_sort_order_check', {
|
|
||||||
check: 'phase_sort_order >= 0'
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
pgm.addConstraint('tasks', 'tasks_member_sort_order_check', {
|
|
||||||
check: 'member_sort_order >= 0'
|
|
||||||
}, { ifNotExists: true });
|
|
||||||
|
|
||||||
// Add indexes for performance
|
|
||||||
pgm.createIndex('tasks', ['project_id', 'status_sort_order'], {
|
|
||||||
name: 'idx_tasks_status_sort_order',
|
|
||||||
ifNotExists: true
|
|
||||||
});
|
|
||||||
|
|
||||||
pgm.createIndex('tasks', ['project_id', 'priority_sort_order'], {
|
|
||||||
name: 'idx_tasks_priority_sort_order',
|
|
||||||
ifNotExists: true
|
|
||||||
});
|
|
||||||
|
|
||||||
pgm.createIndex('tasks', ['project_id', 'phase_sort_order'], {
|
|
||||||
name: 'idx_tasks_phase_sort_order',
|
|
||||||
ifNotExists: true
|
|
||||||
});
|
|
||||||
|
|
||||||
pgm.createIndex('tasks', ['project_id', 'member_sort_order'], {
|
|
||||||
name: 'idx_tasks_member_sort_order',
|
|
||||||
ifNotExists: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add column comments for documentation
|
|
||||||
pgm.sql("COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'");
|
|
||||||
pgm.sql("COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'");
|
|
||||||
pgm.sql("COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'");
|
|
||||||
pgm.sql("COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees'");
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.down = pgm => {
|
|
||||||
// Drop indexes
|
|
||||||
pgm.dropIndex('tasks', ['project_id', 'member_sort_order'], { name: 'idx_tasks_member_sort_order', ifExists: true });
|
|
||||||
pgm.dropIndex('tasks', ['project_id', 'phase_sort_order'], { name: 'idx_tasks_phase_sort_order', ifExists: true });
|
|
||||||
pgm.dropIndex('tasks', ['project_id', 'priority_sort_order'], { name: 'idx_tasks_priority_sort_order', ifExists: true });
|
|
||||||
pgm.dropIndex('tasks', ['project_id', 'status_sort_order'], { name: 'idx_tasks_status_sort_order', ifExists: true });
|
|
||||||
|
|
||||||
// Drop constraints
|
|
||||||
pgm.dropConstraint('tasks', 'tasks_member_sort_order_check', { ifExists: true });
|
|
||||||
pgm.dropConstraint('tasks', 'tasks_phase_sort_order_check', { ifExists: true });
|
|
||||||
pgm.dropConstraint('tasks', 'tasks_priority_sort_order_check', { ifExists: true });
|
|
||||||
pgm.dropConstraint('tasks', 'tasks_status_sort_order_check', { ifExists: true });
|
|
||||||
|
|
||||||
// Drop columns
|
|
||||||
pgm.dropColumns('tasks', ['status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order'], { ifExists: true });
|
|
||||||
};
|
|
||||||
72
worklenz-backend/database/pg-migrations/README.md
Normal file
72
worklenz-backend/database/pg-migrations/README.md
Normal 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
|
||||||
@@ -25,13 +25,6 @@ CREATE TABLE IF NOT EXISTS pg_sessions (
|
|||||||
expire TIMESTAMP(6) NOT NULL
|
expire TIMESTAMP(6) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create pgmigrations table for node-pg-migrate
|
|
||||||
CREATE TABLE IF NOT EXISTS pgmigrations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
run_on TIMESTAMP WITH TIME ZONE NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS project_access_levels (
|
CREATE TABLE IF NOT EXISTS project_access_levels (
|
||||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|||||||
@@ -132,3 +132,139 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index
|
|||||||
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
|
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
|
||||||
ON projects (team_id, name);
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"migrations-dir": "database/pg-migrations",
|
|
||||||
"migrations-schema": "public",
|
|
||||||
"migrations-table": "pgmigrations",
|
|
||||||
"db": {
|
|
||||||
"user": {
|
|
||||||
"ENV": "DB_USER"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"ENV": "DB_PASSWORD"
|
|
||||||
},
|
|
||||||
"host": {
|
|
||||||
"ENV": "DB_HOST"
|
|
||||||
},
|
|
||||||
"port": {
|
|
||||||
"ENV": "DB_PORT"
|
|
||||||
},
|
|
||||||
"database": {
|
|
||||||
"ENV": "DB_NAME"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
476
worklenz-backend/package-lock.json
generated
476
worklenz-backend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
"express-rate-limit": "^6.8.0",
|
"express-rate-limit": "^6.8.0",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^6.15.0",
|
"express-validator": "^6.15.0",
|
||||||
|
"grunt-cli": "^1.5.0",
|
||||||
"helmet": "^6.2.0",
|
"helmet": "^6.2.0",
|
||||||
"hpp": "^0.2.3",
|
"hpp": "^0.2.3",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
@@ -115,7 +116,6 @@
|
|||||||
"jest": "^28.1.3",
|
"jest": "^28.1.3",
|
||||||
"jest-sonar-reporter": "^2.0.0",
|
"jest-sonar-reporter": "^2.0.0",
|
||||||
"ncp": "^2.0.0",
|
"ncp": "^2.0.0",
|
||||||
"node-pg-migrate": "^8.0.3",
|
|
||||||
"nodeman": "^1.1.2",
|
"nodeman": "^1.1.2",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=16.13.0",
|
||||||
"npm": ">=8.11.0",
|
"npm": ">=8.11.0",
|
||||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||||
}
|
}
|
||||||
@@ -6455,12 +6455,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/array-each": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zHjL5SZa68hkKHBFBK6DJCTtr9sfTCPCaph/L7tMSLcTFgy+zX7E+6q5UArbtOtMBCtxdICpfTCspRse+ywyXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/array-flatten": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/array-slice": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/array-union": {
|
"node_modules/array-union": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
|
||||||
@@ -6933,7 +6951,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -8039,6 +8056,15 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-file": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||||
@@ -8898,6 +8924,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expand-tilde": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"homedir-polyfill": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expect": {
|
"node_modules/expect": {
|
||||||
"version": "28.1.3",
|
"version": "28.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/expect/-/expect-28.1.3.tgz",
|
||||||
@@ -9050,6 +9088,12 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-csv": {
|
"node_modules/fast-csv": {
|
||||||
"version": "4.3.6",
|
"version": "4.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||||
@@ -9178,7 +9222,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -9244,6 +9287,46 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/findup-sync": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-file": "^1.0.0",
|
||||||
|
"is-glob": "^4.0.0",
|
||||||
|
"micromatch": "^4.0.2",
|
||||||
|
"resolve-dir": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fined": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fined/-/fined-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZYDqPLGxDkDhDZBjZBb+oD1+j0rA4E0pXY50eplAAOPg2N/gUBSSk5IM1/QhPfyVo19lJ+CvXpqfvk+b2p/8Ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expand-tilde": "^2.0.2",
|
||||||
|
"is-plain-object": "^2.0.3",
|
||||||
|
"object.defaults": "^1.1.0",
|
||||||
|
"object.pick": "^1.2.0",
|
||||||
|
"parse-filepath": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/flagged-respawn": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-lNaHNVymajmk0OJMBn8fVUAU1BtDeKIqKoVhk4xAALB57aALg6b4W0MfJ/cUE0g9YBXy5XhSlPIpYIJ7HaY/3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||||
@@ -9344,6 +9427,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/for-in": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/for-own": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-0OABksIGrxKK8K4kynWkQ7y1zounQxP+CWnyclVwj81KW3vlLlGUx57DKGcP/LH216GzqnstnPocF16Nxs0Ycg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"for-in": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -9741,6 +9845,48 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/global-modules": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"global-prefix": "^1.0.1",
|
||||||
|
"is-windows": "^1.0.1",
|
||||||
|
"resolve-dir": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/global-prefix": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expand-tilde": "^2.0.2",
|
||||||
|
"homedir-polyfill": "^1.0.1",
|
||||||
|
"ini": "^1.3.4",
|
||||||
|
"is-windows": "^1.0.1",
|
||||||
|
"which": "^1.2.14"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/global-prefix/node_modules/which": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"which": "bin/which"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "11.12.0",
|
"version": "11.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||||
@@ -9797,6 +9943,34 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/grunt-cli": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-rILKAFoU0dzlf22SUfDtq2R1fosChXXlJM5j7wI6uoW8gwmXDXzbUvirlKZSYCdXl3LXFbR+8xyS+WFo+b6vlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"grunt-known-options": "~2.0.0",
|
||||||
|
"interpret": "~1.1.0",
|
||||||
|
"liftup": "~3.0.1",
|
||||||
|
"nopt": "~5.0.0",
|
||||||
|
"v8flags": "^4.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"grunt": "bin/grunt"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/grunt-known-options": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/grunt-known-options/-/grunt-known-options-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-GD7cTz0I4SAede1/+pAbmJRG44zFLPipVtdL9o3vqx9IEyb7b4/Y3s7r6ofI3CchR5GvYJ+8buCSioDv5dQLiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -9868,6 +10042,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "https://www.highcharts.com/license"
|
"license": "https://www.highcharts.com/license"
|
||||||
},
|
},
|
||||||
|
"node_modules/homedir-polyfill": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse-passwd": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hpp": {
|
"node_modules/hpp": {
|
||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/hpp/-/hpp-0.2.3.tgz",
|
||||||
@@ -10077,6 +10263,12 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/interpret": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-CLM8SNMDu7C5psFCn6Wg/tgpj/bKAg7hc2gWqcuR9OD5Ft9PhBpIu8PLicPeis+xDd6YX2ncI8MCA64I9tftIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -10086,6 +10278,19 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-absolute": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-relative": "^1.0.0",
|
||||||
|
"is-windows": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
@@ -10147,7 +10352,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -10176,7 +10380,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -10189,7 +10392,6 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
@@ -10205,6 +10407,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isobject": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||||
@@ -10229,6 +10443,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-relative": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-unc-path": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -10241,6 +10467,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-unc-path": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"unc-path-regex": "^0.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-windows": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isarray": {
|
"node_modules/isarray": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
@@ -10251,9 +10498,17 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/isobject": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/istanbul-lib-coverage": {
|
"node_modules/istanbul-lib-coverage": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
@@ -11271,6 +11526,15 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kind-of": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||||
@@ -11362,6 +11626,25 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/liftup": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/liftup/-/liftup-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-yRHaiQDizWSzoXk3APcA71eOI/UuhEkNN9DiW2Tt44mhYzX4joFoCZlxsSOF7RyeLlfqzFLQI1ngFq3ggMPhOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"findup-sync": "^4.0.0",
|
||||||
|
"fined": "^1.2.0",
|
||||||
|
"flagged-respawn": "^1.0.1",
|
||||||
|
"is-plain-object": "^2.0.4",
|
||||||
|
"object.map": "^1.0.1",
|
||||||
|
"rechoir": "^0.7.0",
|
||||||
|
"resolve": "^1.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -11600,6 +11883,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/make-iterator": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"kind-of": "^6.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/makeerror": {
|
"node_modules/makeerror": {
|
||||||
"version": "1.0.12",
|
"version": "1.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||||
@@ -11610,6 +11905,15 @@
|
|||||||
"tmpl": "1.0.5"
|
"tmpl": "1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/map-cache": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -11667,7 +11971,6 @@
|
|||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"braces": "^3.0.3",
|
"braces": "^3.0.3",
|
||||||
@@ -12015,32 +12318,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-pg-migrate": {
|
|
||||||
"version": "8.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.3.tgz",
|
|
||||||
"integrity": "sha512-oKzZyzTULTryO1jehX19VnyPCGf3G/3oWZg3gODphvID56T0WjPOShTVPVnxGdlcueaIW3uAVrr7M8xLZq5TcA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"glob": "~11.0.0",
|
|
||||||
"yargs": "~17.7.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-pg-migrate": "bin/node-pg-migrate.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.11.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/pg": ">=6.0.0 <9.0.0",
|
|
||||||
"pg": ">=4.3.0 <9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/pg": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
||||||
@@ -12141,6 +12418,46 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object.defaults": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-c/K0mw/F11k4dEUBMW8naXUuBuhxRCfG7W+yFy8EcijU/rSmazOUd1XAEEe6bC0OuXY4HUKjTJv7xbxIMqdxrA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"array-each": "^1.0.1",
|
||||||
|
"array-slice": "^1.0.0",
|
||||||
|
"for-own": "^1.0.0",
|
||||||
|
"isobject": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object.map": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-3+mAJu2PLfnSVGHwIWubpOFLscJANBKuB/6A4CxBstc4aqwQY0FWcsppuy4jU5GSB95yES5JHSI+33AWuS4k6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"for-own": "^1.0.0",
|
||||||
|
"make-iterator": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object.pick": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isobject": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -12303,6 +12620,20 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-filepath": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-absolute": "^1.0.0",
|
||||||
|
"map-cache": "^0.2.0",
|
||||||
|
"path-root": "^0.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse-json": {
|
"node_modules/parse-json": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||||
@@ -12322,6 +12653,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-passwd": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parse-srcset": {
|
"node_modules/parse-srcset": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
@@ -12460,6 +12800,27 @@
|
|||||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/path-root": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-root-regex": "^0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-root-regex": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-scurry": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
||||||
@@ -12607,7 +12968,6 @@
|
|||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -13203,6 +13563,18 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rechoir": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redis": {
|
"node_modules/redis": {
|
||||||
"version": "4.7.1",
|
"version": "4.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||||
@@ -13354,6 +13726,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-dir": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expand-tilde": "^2.0.0",
|
||||||
|
"global-modules": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -14589,7 +14974,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -15110,6 +15494,15 @@
|
|||||||
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unc-path-regex": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
@@ -15339,6 +15732,15 @@
|
|||||||
"node": ">=10.12.0"
|
"node": ">=10.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/v8flags": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-fcRLaS4H/hrZk9hYwbdRM35D0U8IYMfEClhXxCivOojl+yTRAZH3Zy2sSy6qVCiGbV9YAtPssP6jaChqC9vPCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/validator": {
|
"node_modules/validator": {
|
||||||
"version": "13.15.15",
|
"version": "13.15.15",
|
||||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz",
|
||||||
|
|||||||
@@ -35,12 +35,7 @@
|
|||||||
"inline-queries": "node ./cli/inline-queries",
|
"inline-queries": "node ./cli/inline-queries",
|
||||||
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
"sonar": "sonar-scanner -Dproject.settings=sonar-project-dev.properties",
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
"test:watch": "jest --watch --setupFiles dotenv/config",
|
"test:watch": "jest --watch --setupFiles dotenv/config"
|
||||||
"migrate": "node-pg-migrate",
|
|
||||||
"migrate:up": "npm run migrate up",
|
|
||||||
"migrate:down": "npm run migrate down",
|
|
||||||
"migrate:create": "npm run migrate create",
|
|
||||||
"migrate:redo": "npm run migrate redo"
|
|
||||||
},
|
},
|
||||||
"jestSonar": {
|
"jestSonar": {
|
||||||
"reportPath": "coverage",
|
"reportPath": "coverage",
|
||||||
@@ -155,7 +150,6 @@
|
|||||||
"jest": "^28.1.3",
|
"jest": "^28.1.3",
|
||||||
"jest-sonar-reporter": "^2.0.0",
|
"jest-sonar-reporter": "^2.0.0",
|
||||||
"ncp": "^2.0.0",
|
"ncp": "^2.0.0",
|
||||||
"node-pg-migrate": "^8.0.3",
|
|
||||||
"nodeman": "^1.1.2",
|
"nodeman": "^1.1.2",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
|||||||
@@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase {
|
|||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||||
|
const search = (req.query.search || "").toString().trim();
|
||||||
|
|
||||||
|
let searchFilter = "";
|
||||||
|
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
|
||||||
|
if (search) {
|
||||||
|
searchFilter = `
|
||||||
|
AND (
|
||||||
|
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||||
|
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
params.push(search);
|
||||||
|
}
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
SELECT ROW_TO_JSON(rec) AS members
|
WITH filtered_members AS (
|
||||||
FROM (SELECT COUNT(*) AS total,
|
SELECT project_members.id,
|
||||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
team_member_id,
|
||||||
FROM (SELECT project_members.id,
|
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
|
||||||
team_member_id,
|
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||||
(SELECT name
|
u.avatar_url,
|
||||||
FROM team_member_info_view
|
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||||
WHERE team_member_info_view.team_member_id = tm.id),
|
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||||
(SELECT email
|
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
|
||||||
FROM team_member_info_view
|
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||||
u.avatar_url,
|
FROM project_members
|
||||||
(SELECT COUNT(*)
|
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||||
FROM tasks
|
LEFT JOIN users u ON tm.user_id = u.id
|
||||||
WHERE archived IS FALSE
|
WHERE project_id = $1
|
||||||
AND project_id = project_members.project_id
|
${search ? searchFilter : ""}
|
||||||
AND id IN (SELECT task_id
|
)
|
||||||
FROM tasks_assignees
|
SELECT
|
||||||
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
(SELECT COUNT(*) FROM filtered_members) AS total,
|
||||||
(SELECT COUNT(*)
|
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||||
FROM tasks
|
FROM (
|
||||||
WHERE archived IS FALSE
|
SELECT * FROM filtered_members
|
||||||
AND project_id = project_members.project_id
|
ORDER BY ${sortField} ${sortOrder}
|
||||||
AND id IN (SELECT task_id
|
LIMIT $3 OFFSET $4
|
||||||
FROM tasks_assignees
|
) t
|
||||||
WHERE tasks_assignees.project_member_id = project_members.id)
|
) AS data
|
||||||
AND status_id IN (SELECT id
|
|
||||||
FROM task_statuses
|
|
||||||
WHERE category_id = (SELECT id
|
|
||||||
FROM sys_task_status_categories
|
|
||||||
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
|
||||||
EXISTS(SELECT email
|
|
||||||
FROM email_invitations
|
|
||||||
WHERE team_member_id = project_members.team_member_id
|
|
||||||
AND email_invitations.team_id = $2) AS pending_invitation,
|
|
||||||
(SELECT project_access_levels.name
|
|
||||||
FROM project_access_levels
|
|
||||||
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
|
||||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
|
||||||
FROM project_members
|
|
||||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
|
||||||
LEFT JOIN users u ON tm.user_id = u.id
|
|
||||||
WHERE project_id = $1
|
|
||||||
ORDER BY ${sortField} ${sortOrder}
|
|
||||||
LIMIT $3 OFFSET $4) t) AS data
|
|
||||||
FROM project_members
|
|
||||||
WHERE project_id = $1) rec;
|
|
||||||
`;
|
`;
|
||||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
|
||||||
|
const result = await db.query(q, params);
|
||||||
const [data] = result.rows;
|
const [data] = result.rows;
|
||||||
|
|
||||||
for (const member of data?.members.data || []) {
|
for (const member of data?.data || []) {
|
||||||
member.progress = member.all_tasks_count > 0
|
member.progress = member.all_tasks_count > 0
|
||||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
|
||||||
}
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Example of updated getMemberTimeSheets method with timezone support
|
||||||
|
// This shows the key changes needed to handle timezones properly
|
||||||
|
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import db from "../../config/db";
|
||||||
|
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../../models/server-response";
|
||||||
|
import { DATE_RANGES } from "../../shared/constants";
|
||||||
|
|
||||||
|
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const archived = req.query.archived === "true";
|
||||||
|
const teams = (req.body.teams || []) as string[];
|
||||||
|
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||||
|
const projects = (req.body.projects || []) as string[];
|
||||||
|
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||||
|
const {billable} = req.body;
|
||||||
|
|
||||||
|
// Get user timezone from request or database
|
||||||
|
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
|
||||||
|
|
||||||
|
if (!teamIds || !projectIds.length)
|
||||||
|
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||||
|
|
||||||
|
const { duration, date_range } = req.body;
|
||||||
|
|
||||||
|
// Calculate date range with timezone support
|
||||||
|
let startDate: moment.Moment;
|
||||||
|
let endDate: moment.Moment;
|
||||||
|
|
||||||
|
if (date_range && date_range.length === 2) {
|
||||||
|
// Convert user's local dates to their timezone's start/end of day
|
||||||
|
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
|
||||||
|
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
|
||||||
|
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||||
|
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||||
|
const minDateResult = await db.query(minDateQuery, []);
|
||||||
|
const minDate = minDateResult.rows[0]?.min_date;
|
||||||
|
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
|
||||||
|
endDate = moment.tz(userTimezone);
|
||||||
|
} else {
|
||||||
|
// Calculate ranges based on user's timezone
|
||||||
|
const now = moment.tz(userTimezone);
|
||||||
|
|
||||||
|
switch (duration) {
|
||||||
|
case DATE_RANGES.YESTERDAY:
|
||||||
|
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||||
|
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_WEEK:
|
||||||
|
startDate = now.clone().subtract(1, "week").startOf("isoWeek");
|
||||||
|
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_MONTH:
|
||||||
|
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||||
|
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_QUARTER:
|
||||||
|
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||||
|
endDate = now.clone().endOf("day");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
startDate = now.clone().startOf("day");
|
||||||
|
endDate = now.clone().endOf("day");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to UTC for database queries
|
||||||
|
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
||||||
|
// Calculate working days in user's timezone
|
||||||
|
const totalDays = endDate.diff(startDate, "days") + 1;
|
||||||
|
let workingDays = 0;
|
||||||
|
|
||||||
|
const current = startDate.clone();
|
||||||
|
while (current.isSameOrBefore(endDate, "day")) {
|
||||||
|
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||||
|
workingDays++;
|
||||||
|
}
|
||||||
|
current.add(1, "day");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated SQL query with proper timezone handling
|
||||||
|
const billableQuery = buildBillableQuery(billable);
|
||||||
|
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
WITH project_hours AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(hours_per_day, 8) as hours_per_day
|
||||||
|
FROM projects
|
||||||
|
WHERE id IN (${projectIds})
|
||||||
|
),
|
||||||
|
total_working_hours AS (
|
||||||
|
SELECT
|
||||||
|
SUM(hours_per_day) * ${workingDays} as total_hours
|
||||||
|
FROM project_hours
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
tm.name,
|
||||||
|
tm.color_code,
|
||||||
|
COALESCE(SUM(twl.time_spent), 0) as logged_time,
|
||||||
|
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
|
||||||
|
(SELECT total_hours FROM total_working_hours) as total_working_hours,
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT total_hours FROM total_working_hours) > 0
|
||||||
|
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
|
||||||
|
ELSE 0
|
||||||
|
END as utilization_percent,
|
||||||
|
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
|
||||||
|
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
|
||||||
|
'${userTimezone}' as user_timezone,
|
||||||
|
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
|
||||||
|
'${endDate.format("YYYY-MM-DD")}' as report_end_date
|
||||||
|
FROM team_members tm
|
||||||
|
LEFT JOIN users u ON tm.user_id = u.id
|
||||||
|
LEFT JOIN task_work_log twl ON twl.user_id = u.id
|
||||||
|
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
WHERE tm.team_id IN (${teamIds})
|
||||||
|
AND (
|
||||||
|
twl.id IS NULL
|
||||||
|
OR (
|
||||||
|
p.id IN (${projectIds})
|
||||||
|
AND twl.created_at >= '${startUtc}'::TIMESTAMP
|
||||||
|
AND twl.created_at <= '${endUtc}'::TIMESTAMP
|
||||||
|
${archivedClause}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
GROUP BY u.id, u.email, tm.name, tm.color_code
|
||||||
|
ORDER BY logged_time DESC`;
|
||||||
|
|
||||||
|
const result = await db.query(q, []);
|
||||||
|
|
||||||
|
// Add timezone context to response
|
||||||
|
const response = {
|
||||||
|
data: result.rows,
|
||||||
|
timezone_info: {
|
||||||
|
user_timezone: userTimezone,
|
||||||
|
report_period: {
|
||||||
|
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||||
|
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||||
|
working_days: workingDays,
|
||||||
|
total_days: totalDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, response));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserTimezone(userId: string): Promise<string> {
|
||||||
|
const q = `SELECT tz.name as timezone
|
||||||
|
FROM users u
|
||||||
|
JOIN timezones tz ON u.timezone_id = tz.id
|
||||||
|
WHERE u.id = $1`;
|
||||||
|
const result = await db.query(q, [userId]);
|
||||||
|
return result.rows[0]?.timezone || "UTC";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
|
||||||
|
if (!billable) return "";
|
||||||
|
|
||||||
|
const { billable: isBillable, nonBillable } = billable;
|
||||||
|
|
||||||
|
if (isBillable && nonBillable) {
|
||||||
|
return "";
|
||||||
|
} else if (isBillable) {
|
||||||
|
return " AND tasks.billable IS TRUE";
|
||||||
|
} else if (nonBillable) {
|
||||||
|
return " AND tasks.billable IS FALSE";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||||
|
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||||
|
import db from "../../config/db";
|
||||||
|
import moment from "moment-timezone";
|
||||||
|
import { DATE_RANGES } from "../../shared/constants";
|
||||||
|
|
||||||
|
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's timezone from the database or request
|
||||||
|
* @param userId - The user ID
|
||||||
|
* @returns The user's timezone or 'UTC' as default
|
||||||
|
*/
|
||||||
|
protected static async getUserTimezone(userId: string): Promise<string> {
|
||||||
|
const q = `SELECT tz.name as timezone
|
||||||
|
FROM users u
|
||||||
|
JOIN timezones tz ON u.timezone_id = tz.id
|
||||||
|
WHERE u.id = $1`;
|
||||||
|
const result = await db.query(q, [userId]);
|
||||||
|
return result.rows[0]?.timezone || 'UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate date range clause with timezone support
|
||||||
|
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||||
|
* @param dateRange - Array of date strings
|
||||||
|
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||||
|
* @returns SQL clause for date filtering
|
||||||
|
*/
|
||||||
|
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||||
|
// For custom date ranges
|
||||||
|
if (dateRange.length === 2) {
|
||||||
|
// Convert dates to user's timezone start/end of day
|
||||||
|
const start = moment.tz(dateRange[0], userTimezone).startOf('day');
|
||||||
|
const end = moment.tz(dateRange[1], userTimezone).endOf('day');
|
||||||
|
|
||||||
|
// Convert to UTC for database comparison
|
||||||
|
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
||||||
|
if (start.isSame(end, 'day')) {
|
||||||
|
// Single day selection
|
||||||
|
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For predefined ranges, calculate based on user's timezone
|
||||||
|
const now = moment.tz(userTimezone);
|
||||||
|
let startDate, endDate;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case DATE_RANGES.YESTERDAY:
|
||||||
|
startDate = now.clone().subtract(1, 'day').startOf('day');
|
||||||
|
endDate = now.clone().subtract(1, 'day').endOf('day');
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_WEEK:
|
||||||
|
startDate = now.clone().subtract(1, 'week').startOf('week');
|
||||||
|
endDate = now.clone().subtract(1, 'week').endOf('week');
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_MONTH:
|
||||||
|
startDate = now.clone().subtract(1, 'month').startOf('month');
|
||||||
|
endDate = now.clone().subtract(1, 'month').endOf('month');
|
||||||
|
break;
|
||||||
|
case DATE_RANGES.LAST_QUARTER:
|
||||||
|
startDate = now.clone().subtract(3, 'months').startOf('day');
|
||||||
|
endDate = now.clone().endOf('day');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
return `AND task_work_log.created_at >= '${startUtc}'::TIMESTAMP AND task_work_log.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format dates for display in user's timezone
|
||||||
|
* @param date - Date to format
|
||||||
|
* @param userTimezone - User's timezone
|
||||||
|
* @param format - Moment format string
|
||||||
|
* @returns Formatted date string
|
||||||
|
*/
|
||||||
|
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format: string = "YYYY-MM-DD HH:mm:ss") {
|
||||||
|
return moment.tz(date, userTimezone).format(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get working days count between two dates in user's timezone
|
||||||
|
* @param startDate - Start date
|
||||||
|
* @param endDate - End date
|
||||||
|
* @param userTimezone - User's timezone
|
||||||
|
* @returns Number of working days
|
||||||
|
*/
|
||||||
|
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
|
||||||
|
const start = moment.tz(startDate, userTimezone);
|
||||||
|
const end = moment.tz(endDate, userTimezone);
|
||||||
|
let workingDays = 0;
|
||||||
|
|
||||||
|
const current = start.clone();
|
||||||
|
while (current.isSameOrBefore(end, 'day')) {
|
||||||
|
// Monday = 1, Friday = 5
|
||||||
|
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||||
|
workingDays++;
|
||||||
|
}
|
||||||
|
current.add(1, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
return workingDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,69 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
|||||||
import { ServerResponse } from "../../models/server-response";
|
import { ServerResponse } from "../../models/server-response";
|
||||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||||
import { formatDuration, getColor, int } from "../../shared/utils";
|
import { formatDuration, getColor, int } from "../../shared/utils";
|
||||||
import ReportingControllerBase from "./reporting-controller-base";
|
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
|
||||||
import Excel from "exceljs";
|
import Excel from "exceljs";
|
||||||
|
|
||||||
export default class ReportingMembersController extends ReportingControllerBase {
|
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
|
||||||
|
|
||||||
|
protected static getPercentage(n: number, total: number) {
|
||||||
|
return +(n ? (n / total) * 100 : 0).toFixed();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
|
||||||
|
return req.user?.team_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static convertSecondsToHoursAndMinutes(seconds: number) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static formatEndDate(endDate: string) {
|
||||||
|
const end = moment(endDate).format("YYYY-MM-DD");
|
||||||
|
const fEndDate = moment(end);
|
||||||
|
return fEndDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static formatCurrentDate() {
|
||||||
|
const current = moment().format("YYYY-MM-DD");
|
||||||
|
const fCurrentDate = moment(current);
|
||||||
|
return fCurrentDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static getDaysLeft(endDate: string): number | null {
|
||||||
|
if (!endDate) return null;
|
||||||
|
|
||||||
|
const fCurrentDate = this.formatCurrentDate();
|
||||||
|
const fEndDate = this.formatEndDate(endDate);
|
||||||
|
|
||||||
|
return fEndDate.diff(fCurrentDate, "days");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static isOverdue(endDate: string): boolean {
|
||||||
|
if (!endDate) return false;
|
||||||
|
|
||||||
|
const fCurrentDate = this.formatCurrentDate();
|
||||||
|
const fEndDate = this.formatEndDate(endDate);
|
||||||
|
|
||||||
|
return fEndDate.isBefore(fCurrentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static isToday(endDate: string): boolean {
|
||||||
|
if (!endDate) return false;
|
||||||
|
|
||||||
|
const fCurrentDate = this.formatCurrentDate();
|
||||||
|
const fEndDate = this.formatEndDate(endDate);
|
||||||
|
|
||||||
|
return fEndDate.isSame(fCurrentDate);
|
||||||
|
}
|
||||||
|
|
||||||
private static async getMembers(
|
private static async getMembers(
|
||||||
teamId: string, searchQuery = "",
|
teamId: string, searchQuery = "",
|
||||||
@@ -487,7 +546,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
dateRange = date_range.split(",");
|
dateRange = date_range.split(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
|
// Get user timezone for proper date filtering
|
||||||
|
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||||
|
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||||
|
|
||||||
@@ -1038,7 +1099,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
|||||||
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
||||||
|
|
||||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
|
// Get user timezone for proper date filtering
|
||||||
|
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||||
|
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||||
|
|
||||||
const billableQuery = this.buildBillableQuery(billable);
|
const billableQuery = this.buildBillableQuery(billable);
|
||||||
@@ -1230,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
|||||||
row.actual_time = int(row.actual_time);
|
row.actual_time = int(row.actual_time);
|
||||||
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
||||||
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
||||||
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
|
row.days_left = this.getDaysLeft(row.end_date);
|
||||||
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
|
row.is_overdue = this.isOverdue(row.end_date);
|
||||||
if (row.days_left && row.is_overdue) {
|
if (row.days_left && row.is_overdue) {
|
||||||
row.days_left = row.days_left.toString().replace(/-/g, "");
|
row.days_left = row.days_left.toString().replace(/-/g, "");
|
||||||
}
|
}
|
||||||
|
|||||||
167
worklenz-backend/src/controllers/survey-controller.ts
Normal file
167
worklenz-backend/src/controllers/survey-controller.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../models/server-response";
|
||||||
|
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||||
|
import HandleExceptions from "../decorators/handle-exceptions";
|
||||||
|
import { ISurveySubmissionRequest } from "../interfaces/survey";
|
||||||
|
import db from "../config/db";
|
||||||
|
|
||||||
|
export default class SurveyController extends WorklenzControllerBase {
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.name,
|
||||||
|
s.description,
|
||||||
|
s.survey_type,
|
||||||
|
s.is_active,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', sq.id,
|
||||||
|
'survey_id', sq.survey_id,
|
||||||
|
'question_key', sq.question_key,
|
||||||
|
'question_type', sq.question_type,
|
||||||
|
'is_required', sq.is_required,
|
||||||
|
'sort_order', sq.sort_order,
|
||||||
|
'options', sq.options
|
||||||
|
) ORDER BY sq.sort_order
|
||||||
|
) FILTER (WHERE sq.id IS NOT NULL),
|
||||||
|
'[]'
|
||||||
|
) AS questions
|
||||||
|
FROM surveys s
|
||||||
|
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
|
||||||
|
WHERE s.survey_type = 'account_setup' AND s.is_active = true
|
||||||
|
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q);
|
||||||
|
const [survey] = result.rows;
|
||||||
|
|
||||||
|
if (!survey) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, survey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const body = req.body as ISurveySubmissionRequest;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has already submitted a response for this survey
|
||||||
|
const existingResponseQuery = `
|
||||||
|
SELECT id FROM survey_responses
|
||||||
|
WHERE user_id = $1 AND survey_id = $2;
|
||||||
|
`;
|
||||||
|
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
|
||||||
|
|
||||||
|
let responseId: string;
|
||||||
|
|
||||||
|
if (existingResult.rows.length > 0) {
|
||||||
|
// Update existing response
|
||||||
|
responseId = existingResult.rows[0].id;
|
||||||
|
|
||||||
|
const updateResponseQuery = `
|
||||||
|
UPDATE survey_responses
|
||||||
|
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
`;
|
||||||
|
await db.query(updateResponseQuery, [responseId]);
|
||||||
|
|
||||||
|
// Delete existing answers
|
||||||
|
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
|
||||||
|
await db.query(deleteAnswersQuery, [responseId]);
|
||||||
|
} else {
|
||||||
|
// Create new response
|
||||||
|
const createResponseQuery = `
|
||||||
|
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
|
||||||
|
VALUES ($1, $2, true, NOW())
|
||||||
|
RETURNING id;
|
||||||
|
`;
|
||||||
|
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
|
||||||
|
responseId = responseResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new answers
|
||||||
|
if (body.answers.length > 0) {
|
||||||
|
const answerValues: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
body.answers.forEach((answer, index) => {
|
||||||
|
const baseIndex = index * 4;
|
||||||
|
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
|
||||||
|
|
||||||
|
params.push(
|
||||||
|
responseId,
|
||||||
|
answer.question_id,
|
||||||
|
answer.answer_text || null,
|
||||||
|
answer.answer_json ? JSON.stringify(answer.answer_json) : null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertAnswersQuery = `
|
||||||
|
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
|
||||||
|
VALUES ${answerValues.join(', ')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
await db.query(insertAnswersQuery, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const surveyId = req.params.survey_id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
SELECT
|
||||||
|
sr.id,
|
||||||
|
sr.survey_id,
|
||||||
|
sr.user_id,
|
||||||
|
sr.is_completed,
|
||||||
|
sr.started_at,
|
||||||
|
sr.completed_at,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'question_id', sa.question_id,
|
||||||
|
'answer_text', sa.answer_text,
|
||||||
|
'answer_json', sa.answer_json
|
||||||
|
)
|
||||||
|
) FILTER (WHERE sa.id IS NOT NULL),
|
||||||
|
'[]'
|
||||||
|
) AS answers
|
||||||
|
FROM survey_responses sr
|
||||||
|
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
|
||||||
|
WHERE sr.user_id = $1 AND sr.survey_id = $2
|
||||||
|
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [userId, surveyId]);
|
||||||
|
const [response] = result.rows;
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, response));
|
||||||
|
}
|
||||||
|
}
|
||||||
37
worklenz-backend/src/interfaces/survey.ts
Normal file
37
worklenz-backend/src/interfaces/survey.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface ISurveyQuestion {
|
||||||
|
id: string;
|
||||||
|
survey_id: string;
|
||||||
|
question_key: string;
|
||||||
|
question_type: 'single_choice' | 'multiple_choice' | 'text';
|
||||||
|
is_required: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
options?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISurvey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
survey_type: 'account_setup' | 'onboarding' | 'feedback';
|
||||||
|
is_active: boolean;
|
||||||
|
questions?: ISurveyQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISurveyAnswer {
|
||||||
|
question_id: string;
|
||||||
|
answer_text?: string;
|
||||||
|
answer_json?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISurveyResponse {
|
||||||
|
id?: string;
|
||||||
|
survey_id: string;
|
||||||
|
user_id?: string;
|
||||||
|
is_completed: boolean;
|
||||||
|
answers: ISurveyAnswer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISurveySubmissionRequest {
|
||||||
|
survey_id: string;
|
||||||
|
answers: ISurveyAnswer[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextFunction } from "express";
|
||||||
|
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||||
|
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||||
|
import { ServerResponse } from "../../models/server-response";
|
||||||
|
import { ISurveySubmissionRequest } from "../../interfaces/survey";
|
||||||
|
|
||||||
|
export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||||
|
const body = req.body as ISurveySubmissionRequest;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Request body is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.survey_id || typeof body.survey_id !== 'string') {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.answers || !Array.isArray(body.answers)) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each answer
|
||||||
|
for (let i = 0; i < body.answers.length; i++) {
|
||||||
|
const answer = body.answers[i];
|
||||||
|
|
||||||
|
if (!answer.question_id || typeof answer.question_id !== 'string') {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one of answer_text or answer_json should be provided
|
||||||
|
if (!answer.answer_text && !answer.answer_json) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Either answer_text or answer_json is required`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate answer_text if provided
|
||||||
|
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate answer_json if provided
|
||||||
|
if (answer.answer_json && !Array.isArray(answer.answer_json)) {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate answer_json items are strings
|
||||||
|
if (answer.answer_json) {
|
||||||
|
for (let j = 0; j < answer.answer_json.length; j++) {
|
||||||
|
if (typeof answer.answer_json[j] !== 'string') {
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|
||||||
"setupYourAccount": "Setup Your Worklenz Account.",
|
"setupYourAccount": "Setup Your Account.",
|
||||||
"organizationStepTitle": "Name Your Organization",
|
"organizationStepTitle": "Name Your Organization",
|
||||||
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import roadmapApiRouter from "./gannt-apis/roadmap-api-router";
|
|||||||
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
import scheduleApiRouter from "./gannt-apis/schedule-api-router";
|
||||||
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
import scheduleApiV2Router from "./gannt-apis/schedule-api-v2-router";
|
||||||
import projectManagerApiRouter from "./project-managers-api-router";
|
import projectManagerApiRouter from "./project-managers-api-router";
|
||||||
|
import surveyApiRouter from "./survey-api-router";
|
||||||
|
|
||||||
import billingApiRouter from "./billing-api-router";
|
import billingApiRouter from "./billing-api-router";
|
||||||
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
import taskDependenciesApiRouter from "./task-dependencies-api-router";
|
||||||
@@ -103,6 +104,7 @@ api.use("/roadmap-gannt", roadmapApiRouter);
|
|||||||
api.use("/roadmap-gannt", roadmapApiRouter);
|
api.use("/roadmap-gannt", roadmapApiRouter);
|
||||||
api.use("/schedule-gannt", scheduleApiRouter);
|
api.use("/schedule-gannt", scheduleApiRouter);
|
||||||
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
api.use("/schedule-gannt-v2", scheduleApiV2Router);
|
||||||
|
api.use("/project-managers", projectManagerApiRouter);
|
||||||
api.use("/surveys", surveyApiRouter);
|
api.use("/surveys", surveyApiRouter);
|
||||||
|
|
||||||
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
api.get("/overview/:id", safeControllerFunction(OverviewController.getById));
|
||||||
|
|||||||
17
worklenz-backend/src/routes/apis/survey-api-router.ts
Normal file
17
worklenz-backend/src/routes/apis/survey-api-router.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import express from "express";
|
||||||
|
import SurveyController from "../../controllers/survey-controller";
|
||||||
|
import surveySubmissionValidator from "../../middlewares/validators/survey-submission-validator";
|
||||||
|
import safeControllerFunction from "../../shared/safe-controller-function";
|
||||||
|
|
||||||
|
const surveyApiRouter = express.Router();
|
||||||
|
|
||||||
|
// Get account setup survey with questions
|
||||||
|
surveyApiRouter.get("/account-setup", safeControllerFunction(SurveyController.getAccountSetupSurvey));
|
||||||
|
|
||||||
|
// Submit survey response
|
||||||
|
surveyApiRouter.post("/responses", surveySubmissionValidator, safeControllerFunction(SurveyController.submitSurveyResponse));
|
||||||
|
|
||||||
|
// Get user's survey response for a specific survey
|
||||||
|
surveyApiRouter.get("/responses/:survey_id", safeControllerFunction(SurveyController.getUserSurveyResponse));
|
||||||
|
|
||||||
|
export default surveyApiRouter;
|
||||||
@@ -57,116 +57,15 @@
|
|||||||
<!-- Environment configuration -->
|
<!-- Environment configuration -->
|
||||||
<script src="/env-config.js"></script>
|
<script src="/env-config.js"></script>
|
||||||
|
|
||||||
<!-- Optimized Google Analytics with reduced blocking -->
|
<!-- Analytics Module -->
|
||||||
<script>
|
<script src="/js/analytics.js"></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>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="./src/index.tsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
<script type="text/javascript">
|
<!-- HubSpot Integration Module -->
|
||||||
// Load HubSpot script asynchronously and only for production
|
<script src="/js/hubspot.js"></script>
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
97
worklenz-frontend/public/js/analytics.js
Normal file
97
worklenz-frontend/public/js/analytics.js
Normal 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();
|
||||||
|
});
|
||||||
137
worklenz-frontend/public/js/hubspot.js
Normal file
137
worklenz-frontend/public/js/hubspot.js
Normal 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;
|
||||||
|
});
|
||||||
@@ -1,31 +1,191 @@
|
|||||||
{
|
{
|
||||||
"continue": "Vazhdo",
|
"continue": "Vazhdo",
|
||||||
|
|
||||||
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
|
"setupYourAccount": "Konfiguro llogarinë tënde.",
|
||||||
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
|
"organizationStepTitle": "Emërto organizatën tënde",
|
||||||
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
"organizationStepLabel": "Zgjidh një emër për llogarinë tënde në Worklenz.",
|
||||||
|
"organizationStepWelcome": "Konfiguro llogarinë tënde në Worklenz.",
|
||||||
"projectStepTitle": "Krijoni projektin tuaj të parë",
|
"organizationStepDescription": "Le të fillojmë duke konfiguruar organizatën tënde. Kjo do të jetë hapësira kryesore e punës për ekipin tënd.",
|
||||||
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
|
"organizationStepTooltip": "Ky emër do të shfaqet në hapësirën tënde të punës dhe mund të ndryshohet më vonë në cilësime.",
|
||||||
|
"organizationStepNeedIdeas": "Keni nevojë për ide?",
|
||||||
|
"organizationStepUseDetected": "Përdorimi i zbuluar:",
|
||||||
|
"organizationStepCharacters": "karaktere",
|
||||||
|
"organizationStepGoodLength": "Gjatësi e mirë",
|
||||||
|
"organizationStepTooShort": "Shumë i shkurtër",
|
||||||
|
"organizationStepNamingTips": "Këshilla për emërtimin",
|
||||||
|
"organizationStepTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
|
||||||
|
"organizationStepTip2": "Përfaqëso industrinë ose vlerat e tua",
|
||||||
|
"organizationStepTip3": "Mendo për rritjen në të ardhmen",
|
||||||
|
"organizationStepTip4": "Bëje unik dhe të përshtatshëm për markë",
|
||||||
|
"organizationStepSuggestionsTitle": "Sugjerime për emra",
|
||||||
|
"organizationStepCategory1": "Kompani Teknologjie",
|
||||||
|
"organizationStepCategory2": "Agjenci Kreative",
|
||||||
|
"organizationStepCategory3": "Konsulencë",
|
||||||
|
"organizationStepCategory4": "Startupe",
|
||||||
|
"organizationStepSuggestionsNote": "Këto janë vetëm shembuj për të të ndihmuar të fillosh. Zgjidh diçka që përfaqëson organizatën tënde.",
|
||||||
|
"organizationStepPrivacyNote": "Emri i organizatës tënde është privat dhe i dukshëm vetëm për anëtarët e ekipit.",
|
||||||
|
"projectStepTitle": "Krijo projektin tënd të parë",
|
||||||
|
"projectStepLabel": "Në cilin projekt po punon tani?",
|
||||||
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
||||||
|
"tasksStepTitle": "Krijo detyrat e tua të para",
|
||||||
"tasksStepTitle": "Krijoni detyrat tuaja të para",
|
"tasksStepLabel": "Shkruaj disa detyra që do të kryesh në",
|
||||||
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
|
|
||||||
"tasksStepAddAnother": "Shto një tjetër",
|
"tasksStepAddAnother": "Shto një tjetër",
|
||||||
|
"emailPlaceholder": "Adresa e emailit",
|
||||||
"emailPlaceholder": "Adresa email",
|
"invalidEmail": "Ju lutem vendosni një adresë emaili të vlefshme",
|
||||||
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
|
|
||||||
"or": "ose",
|
"or": "ose",
|
||||||
"templateButton": "Importo nga shablloni",
|
"templateButton": "Importo nga shablloni",
|
||||||
"goBack": "Kthehu Mbrapa",
|
"goBack": "Kthehu mbrapa",
|
||||||
"cancel": "Anulo",
|
"cancel": "Anulo",
|
||||||
"create": "Krijo",
|
"create": "Krijo",
|
||||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||||
"step3InputLabel": "Fto me email",
|
"step3InputLabel": "Fto me email",
|
||||||
"addAnother": "Shto një tjetër",
|
"addAnother": "Shto një tjetër",
|
||||||
"skipForNow": "Kalo tani për tani",
|
"skipForNow": "Kalo për tani",
|
||||||
"formTitle": "Krijoni detyrën tuaj të parë.",
|
"formTitle": "Krijo detyrën tënde të parë.",
|
||||||
"step3Title": "Fto ekipin tënd të punojë me",
|
"step3Title": "Fto ekipin tënd për të punuar së bashku",
|
||||||
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
||||||
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
|
"maxTasks": " (Mund të krijoni deri në 5 detyra)",
|
||||||
|
"membersStepTitle": "Fto ekipin tënd",
|
||||||
|
"membersStepDescription": "Shto anëtarë ekipi në \"{{organizationName}}\" dhe filloni bashkëpunimin",
|
||||||
|
"memberPlaceholder": "Anëtari i ekipit {{index}} - Shkruani adresën e emailit",
|
||||||
|
"validEmailAddress": "Adresë emaili e vlefshme",
|
||||||
|
"addAnotherTeamMember": "Shto një anëtar tjetër të ekipit ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "Gjithmonë mund të ftoni anëtarë të ekipit më vonë",
|
||||||
|
"skipStepDescription": "Nuk i keni adresat e emailit gati? Asnjë problem! Mund ta kaloni këtë hap dhe të ftoni anëtarë nga paneli i projektit më vonë.",
|
||||||
|
"orgCategoryTech": "Kompani Teknologjie",
|
||||||
|
"orgCategoryCreative": "Agjenci Kreative",
|
||||||
|
"orgCategoryConsulting": "Konsulencë",
|
||||||
|
"orgCategoryStartups": "Startupe",
|
||||||
|
"namingTip1": "Mbaje të thjeshtë dhe të lehtë për t'u mbajtur mend",
|
||||||
|
"namingTip2": "Përfaqëso industrinë ose vlerat e tua",
|
||||||
|
"namingTip3": "Mendo për rritjen në të ardhmen",
|
||||||
|
"namingTip4": "Bëje unik dhe të përshtatshëm për markë",
|
||||||
|
"aboutYouTitle": "Na trego për veten tënde",
|
||||||
|
"aboutYouDescription": "Na ndihmo të personalizojmë përvojën tënde",
|
||||||
|
"orgTypeQuestion": "Cila përshkruan më mirë organizatën tënde?",
|
||||||
|
"userRoleQuestion": "Cili është roli yt?",
|
||||||
|
"yourNeedsTitle": "Cilat janë nevojat e tua kryesore?",
|
||||||
|
"yourNeedsDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
|
||||||
|
"yourNeedsQuestion": "Si do ta përdorësh kryesisht Worklenz?",
|
||||||
|
"useCaseTaskOrg": "Organizo dhe ndiq detyrat",
|
||||||
|
"useCaseTeamCollab": "Puno së bashku pa pengesa",
|
||||||
|
"useCaseResourceMgmt": "Menaxho kohën dhe burimet",
|
||||||
|
"useCaseClientComm": "Qëndro i lidhur me klientët",
|
||||||
|
"useCaseTimeTrack": "Monitoro orët e projektit",
|
||||||
|
"useCaseOther": "Diçka tjetër",
|
||||||
|
"selectedText": "zgjedhur",
|
||||||
|
"previousToolsQuestion": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
|
||||||
|
"discoveryTitle": "Edhe një gjë e fundit...",
|
||||||
|
"discoveryDescription": "Na ndihmo të kuptojmë si e zbulove Worklenz",
|
||||||
|
"discoveryQuestion": "Si dëgjove për ne?",
|
||||||
|
"allSetTitle": "Çdo gjë gati!",
|
||||||
|
"allSetDescription": "Le të krijojmë projektin tënd të parë dhe të fillojmë me Worklenz",
|
||||||
|
"aboutYouStepName": "Rreth teje",
|
||||||
|
"yourNeedsStepName": "Nevojat e tua",
|
||||||
|
"discoveryStepName": "Zbulimi",
|
||||||
|
"stepProgress": "Hapi {step} nga 3: {title}",
|
||||||
|
"projectStepHeader": "Le të krijojmë projektin tënd të parë",
|
||||||
|
"projectStepSubheader": "Fillo nga e para ose përdor një shabllon për të filluar më shpejt",
|
||||||
|
"startFromScratch": "Fillo nga e para",
|
||||||
|
"templateSelected": "Shablloni i zgjedhur më poshtë",
|
||||||
|
"quickSuggestions": "Sugjerime të shpejta:",
|
||||||
|
"orText": "OSE",
|
||||||
|
"startWithTemplate": "Fillo me një shabllon",
|
||||||
|
"clearToSelectTemplate": "Pastro emrin e projektit më sipër për të zgjedhur një shabllon",
|
||||||
|
"templateHeadStart": "Fillo më shpejt me struktura të gatshme projekti",
|
||||||
|
"browseAllTemplates": "Shfleto të gjitha shabllonet",
|
||||||
|
"templatesAvailable": "15+ shabllone të specializuara sipas industrisë në dispozicion",
|
||||||
|
"chooseTemplate": "Zgjidh një shabllon që i përshtatet llojit të projektit tënd",
|
||||||
|
"createProject": "Krijo projekt",
|
||||||
|
"templateSoftwareDev": "Zhvillim Softueri",
|
||||||
|
"templateSoftwareDesc": "Sprint-e agile, ndjekje gabimesh, lëshime",
|
||||||
|
"templateMarketing": "Fushatë Marketingu",
|
||||||
|
"templateMarketingDesc": "Planifikim fushate, kalendar përmbajtjesh",
|
||||||
|
"templateConstruction": "Projekt Ndërtimi",
|
||||||
|
"templateConstructionDesc": "Faza, leje, kontraktorë",
|
||||||
|
"templateStartup": "Lansim Startup-i",
|
||||||
|
"templateStartupDesc": "Zhvillim MVP, financim, rritje",
|
||||||
|
"tasksStepDescription": "Ndaji \"{{projectName}}\" në detyra të veprueshme për të filluar",
|
||||||
|
"taskPlaceholder": "Detyra {{index}} - p.sh., Çfarë duhet bërë?",
|
||||||
|
"addAnotherTask": "Shto një detyrë tjetër ({{current}}/{{max}})",
|
||||||
|
"surveyStepTitle": "Na trego për veten tënde",
|
||||||
|
"surveyStepLabel": "Na ndihmo të personalizojmë përvojën tënde në Worklenz duke iu përgjigjur disa pyetjeve.",
|
||||||
|
"organizationType": "Cila përshkruan më mirë organizatën tënde?",
|
||||||
|
"organizationTypeFreelancer": "Freelancer",
|
||||||
|
"organizationTypeStartup": "Startup",
|
||||||
|
"organizationTypeSmallMediumBusiness": "Biznes i Vogël ose i Mesëm",
|
||||||
|
"organizationTypeAgency": "Agjenci",
|
||||||
|
"organizationTypeEnterprise": "Ndërmarrje",
|
||||||
|
"organizationTypeOther": "Tjetër",
|
||||||
|
"userRole": "Cili është roli yt?",
|
||||||
|
"userRoleFounderCeo": "Themelues / CEO",
|
||||||
|
"userRoleProjectManager": "Menaxher Projekti",
|
||||||
|
"userRoleSoftwareDeveloper": "Zhvillues Softueri",
|
||||||
|
"userRoleDesigner": "Dizajner",
|
||||||
|
"userRoleOperations": "Operacionet",
|
||||||
|
"userRoleOther": "Tjetër",
|
||||||
|
"mainUseCases": "Për çfarë do ta përdorësh kryesisht Worklenz?",
|
||||||
|
"mainUseCasesTaskManagement": "Menaxhim detyrash",
|
||||||
|
"mainUseCasesTeamCollaboration": "Bashkëpunim ekipi",
|
||||||
|
"mainUseCasesResourcePlanning": "Planifikim burimesh",
|
||||||
|
"mainUseCasesClientCommunication": "Komunikim & raportim me klientët",
|
||||||
|
"mainUseCasesTimeTracking": "Ndjekje kohe",
|
||||||
|
"mainUseCasesOther": "Tjetër",
|
||||||
|
"previousTools": "Çfarë mjetesh ke përdorur para Worklenz?",
|
||||||
|
"previousToolsPlaceholder": "p.sh. Trello, Asana, Monday.com",
|
||||||
|
"howHeardAbout": "Si dëgjove për Worklenz?",
|
||||||
|
"howHeardAboutGoogleSearch": "Kërkim në Google",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "Një mik ose koleg",
|
||||||
|
"howHeardAboutBlogArticle": "Një blog ose artikull",
|
||||||
|
"howHeardAboutOther": "Tjetër",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "Na trego për veten",
|
||||||
|
"aboutYouStepDescription": "Na ndihmo të personalizojmë përvojën tënde",
|
||||||
|
"yourNeedsStepTitle": "Cilat janë nevojat e tua kryesore?",
|
||||||
|
"yourNeedsStepDescription": "Zgjidh të gjitha që aplikohen për të na ndihmuar të konfigurojmë hapësirën tënde të punës",
|
||||||
|
"selected": "zgjedhur",
|
||||||
|
"previousToolsLabel": "Çfarë mjetesh ke përdorur më parë? (Opsionale)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX, Grafikë, Kreativ",
|
||||||
|
"developer": "Frontend, Backend, Full-stack",
|
||||||
|
"projectManager": "Planifikim, Koordinim",
|
||||||
|
"marketing": "Përmbajtje, Media Sociale, Rritje",
|
||||||
|
"sales": "Zhvillim Biznesi, Marrëdhënie me Klientë",
|
||||||
|
"operations": "Administratë, HR, Financa"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "Anglisht",
|
||||||
|
"es": "Spanjisht",
|
||||||
|
"pt": "Portugalisht",
|
||||||
|
"de": "Gjermanisht",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "Kinezçe"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["Projekti i Klientit", "Përditësim Portfolio", "Markë Personale"],
|
||||||
|
"startup": ["Zhvillim MVP", "Lansim Produkti", "Kërkim Tregu"],
|
||||||
|
"agency": ["Fushatë Klienti", "Strategji Markë", "Ridizajnim Website"],
|
||||||
|
"enterprise": ["Migrim Sistemi", "Optimizim Procesesh", "Trajnim Ekipi"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "Organizoj dhe ndjek detyrat",
|
||||||
|
"teamCollaboration": "Punojmë së bashku pa probleme",
|
||||||
|
"resourcePlanning": "Menaxhoj kohën dhe burimet",
|
||||||
|
"clientCommunication": "Qëndroj i lidhur me klientët",
|
||||||
|
"timeTracking": "Monitoroj orët e projektit",
|
||||||
|
"other": "Diçka tjetër"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
"deleteConfirmationOk": "Po",
|
"deleteConfirmationOk": "Po",
|
||||||
"deleteConfirmationCancel": "Anulo",
|
"deleteConfirmationCancel": "Anulo",
|
||||||
|
|
||||||
|
"deleteTaskTitle": "Fshi Detyrën",
|
||||||
|
"deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.",
|
||||||
|
"deleteTaskConfirm": "Fshi",
|
||||||
|
"deleteTaskCancel": "Anulo",
|
||||||
|
|
||||||
|
"deleteStatusTitle": "Fshi Statusin",
|
||||||
|
"deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.",
|
||||||
|
|
||||||
|
"deletePhaseTitle": "Fshi Fazen",
|
||||||
|
"deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.",
|
||||||
|
|
||||||
"dueDate": "Data e përfundimit",
|
"dueDate": "Data e përfundimit",
|
||||||
"cancel": "Anulo",
|
"cancel": "Anulo",
|
||||||
|
|
||||||
@@ -26,5 +37,17 @@
|
|||||||
"noDueDate": "Pa datë përfundimi",
|
"noDueDate": "Pa datë përfundimi",
|
||||||
"save": "Ruaj",
|
"save": "Ruaj",
|
||||||
"clear": "Pastro",
|
"clear": "Pastro",
|
||||||
"nextWeek": "Javën e ardhshme"
|
"nextWeek": "Javën e ardhshme",
|
||||||
|
"noSubtasks": "Pa nëndetyra",
|
||||||
|
"showSubtasks": "Shfaq nëndetyrat",
|
||||||
|
"hideSubtasks": "Fshih nëndetyrat",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave",
|
||||||
|
"noTasksFound": "Nuk u gjetën detyra",
|
||||||
|
"loadingFilters": "Duke ngarkuar filtra...",
|
||||||
|
"failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave",
|
||||||
|
"failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave",
|
||||||
|
"pleaseTryAgain": "Ju lutemi provoni përsëri",
|
||||||
|
"taskNotCompleted": "Detyra nuk është përfunduar",
|
||||||
|
"completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"deleteButtonTooltip": "Hiq nga projekti",
|
"deleteButtonTooltip": "Hiq nga projekti",
|
||||||
"memberCount": "Anëtar",
|
"memberCount": "Anëtar",
|
||||||
"membersCountPlural": "Anëtarë",
|
"membersCountPlural": "Anëtarë",
|
||||||
"emptyText": "Nuk ka bashkëngjitje në projekt."
|
"emptyText": "Nuk ka bashkëngjitje në projekt.",
|
||||||
|
"searchPlaceholder": "Kërko anëtarë"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,9 @@
|
|||||||
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
"searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre",
|
||||||
"searchPlaceholder": "Shkruani emrin ose email-in",
|
"searchPlaceholder": "Shkruani emrin ose email-in",
|
||||||
"inviteAsAMember": "Fto si anëtar",
|
"inviteAsAMember": "Fto si anëtar",
|
||||||
"inviteNewMemberByEmail": "Fto anëtar të ri me email"
|
"inviteNewMemberByEmail": "Fto anëtar të ri me email",
|
||||||
|
"members": "Anëtarë",
|
||||||
|
"copyProjectLink": "Kopjo lidhjen e projektit",
|
||||||
|
"inviteMember": "Fto anëtar",
|
||||||
|
"alsoInviteToProject": "Fto edhe në projekt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Titulli i Punës",
|
"jobTitleLabel": "Titulli i Punës",
|
||||||
"jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)",
|
"jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)",
|
||||||
"memberAccessLabel": "Niveli i Qasjes",
|
"memberAccessLabel": "Niveli i Qasjes",
|
||||||
"addToTeamButton": "Shto Anëtar në Ekip",
|
"addToTeamButton": "Dërgo ftesën",
|
||||||
"updateButton": "Ruaj Ndryshimet",
|
"updateButton": "Ruaj Ndryshimet",
|
||||||
"resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës",
|
"resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës",
|
||||||
"invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!",
|
"invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "Përditësuar",
|
"updatedText": "Përditësuar",
|
||||||
"noResultFound": "Shkruani një adresë email dhe shtypni Enter...",
|
"noResultFound": "Shkruani një adresë email dhe shtypni Enter...",
|
||||||
"jobTitlesFetchError": "Dështoi marrja e titujve të punës",
|
"jobTitlesFetchError": "Dështoi marrja e titujve të punës",
|
||||||
"invitationResent": "Ftesa u dërgua sërish me sukses!"
|
"invitationResent": "Ftesa u dërgua sërish me sukses!",
|
||||||
|
"copyTeamLink": "Kopjo lidhjen e ekipit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,28 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.",
|
"setupYourAccount": "Richten Sie Ihr Worklenz-Konto ein.",
|
||||||
"organizationStepTitle": "Organisation benennen",
|
"organizationStepTitle": "Organisation benennen",
|
||||||
"organizationStepLabel": "Wählen Sie einen Namen für Ihr Worklenz-Konto.",
|
"organizationStepWelcome": "Willkommen bei Worklenz!",
|
||||||
|
"organizationStepDescription": "Beginnen wir mit der Einrichtung Ihrer Organisation. Dies wird der Hauptarbeitsplatz für Ihr Team.",
|
||||||
|
"organizationStepLabel": "Organisationsname",
|
||||||
|
"organizationStepPlaceholder": "z.B. Acme Corporation",
|
||||||
|
"organizationStepTooltip": "Dieser Name wird in Ihrem Arbeitsbereich angezeigt und kann später in den Einstellungen geändert werden.",
|
||||||
|
"organizationStepNeedIdeas": "Brauchen Sie Ideen?",
|
||||||
|
"organizationStepUseDetected": "Erkannt verwenden:",
|
||||||
|
"organizationStepCharacters": "Zeichen",
|
||||||
|
"organizationStepGoodLength": "Gute Länge",
|
||||||
|
"organizationStepTooShort": "Zu kurz",
|
||||||
|
"organizationStepNamingTips": "Namensgebungstipps",
|
||||||
|
"organizationStepTip1": "Halten Sie es einfach und einprägsam",
|
||||||
|
"organizationStepTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
|
||||||
|
"organizationStepTip3": "Denken Sie an zukünftiges Wachstum",
|
||||||
|
"organizationStepTip4": "Machen Sie es einzigartig und markenfähig",
|
||||||
|
"organizationStepSuggestionsTitle": "Namensvorschläge",
|
||||||
|
"organizationStepCategory1": "Tech-Unternehmen",
|
||||||
|
"organizationStepCategory2": "Kreativagenturen",
|
||||||
|
"organizationStepCategory3": "Beratung",
|
||||||
|
"organizationStepCategory4": "Startups",
|
||||||
|
"organizationStepSuggestionsNote": "Dies sind nur Beispiele für den Einstieg. Wählen Sie etwas, das Ihre Organisation repräsentiert.",
|
||||||
|
"organizationStepPrivacyNote": "Ihr Organisationsname ist privat und nur für Ihre Teammitglieder sichtbar.",
|
||||||
|
|
||||||
"projectStepTitle": "Erstellen Sie Ihr erstes Projekt",
|
"projectStepTitle": "Erstellen Sie Ihr erstes Projekt",
|
||||||
"projectStepLabel": "An welchem Projekt arbeiten Sie gerade?",
|
"projectStepLabel": "An welchem Projekt arbeiten Sie gerade?",
|
||||||
@@ -27,5 +48,164 @@
|
|||||||
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
"formTitle": "Erstellen Sie Ihre erste Aufgabe.",
|
||||||
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
"step3Title": "Laden Sie Ihr Team zur Zusammenarbeit ein",
|
||||||
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
"maxMembers": " (Sie können bis zu 5 Mitglieder einladen)",
|
||||||
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)"
|
"maxTasks": " (Sie können bis zu 5 Aufgaben erstellen)",
|
||||||
|
|
||||||
|
"membersStepTitle": "Laden Sie Ihr Team ein",
|
||||||
|
"membersStepDescription": "Teammitglieder zu \"{{organizationName}}\" hinzufügen und mit der Zusammenarbeit beginnen",
|
||||||
|
"memberPlaceholder": "Teammitglied {{index}} - E-Mail-Adresse eingeben",
|
||||||
|
"validEmailAddress": "Gültige E-Mail-Adresse",
|
||||||
|
"addAnotherTeamMember": "Weiteres Teammitglied hinzufügen ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "Sie können Teammitglieder jederzeit später einladen",
|
||||||
|
"skipStepDescription": "Haben Sie keine E-Mail-Adressen bereit? Kein Problem! Sie können diesen Schritt überspringen und Teammitglieder später über Ihr Projekt-Dashboard einladen.",
|
||||||
|
|
||||||
|
"orgCategoryTech": "Technologieunternehmen",
|
||||||
|
"orgCategoryCreative": "Kreativagenturen",
|
||||||
|
"orgCategoryConsulting": "Beratung",
|
||||||
|
"orgCategoryStartups": "Startups",
|
||||||
|
"namingTip1": "Halten Sie es einfach und einprägsam",
|
||||||
|
"namingTip2": "Spiegeln Sie Ihre Branche oder Werte wider",
|
||||||
|
"namingTip3": "Denken Sie an zukünftiges Wachstum",
|
||||||
|
"namingTip4": "Machen Sie es einzigartig und markenfähig",
|
||||||
|
|
||||||
|
"aboutYouTitle": "Erzählen Sie uns von sich",
|
||||||
|
"aboutYouDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
|
||||||
|
"orgTypeQuestion": "Was beschreibt Ihre Organisation am besten?",
|
||||||
|
"userRoleQuestion": "Was ist Ihre Rolle?",
|
||||||
|
|
||||||
|
"yourNeedsTitle": "Was sind Ihre Hauptbedürfnisse?",
|
||||||
|
"yourNeedsDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
|
||||||
|
"yourNeedsQuestion": "Wie werden Sie Worklenz hauptsächlich nutzen?",
|
||||||
|
"useCaseTaskOrg": "Aufgaben organisieren und verfolgen",
|
||||||
|
"useCaseTeamCollab": "Nahtlos zusammenarbeiten",
|
||||||
|
"useCaseResourceMgmt": "Zeit und Ressourcen verwalten",
|
||||||
|
"useCaseClientComm": "Mit Kunden in Verbindung bleiben",
|
||||||
|
"useCaseTimeTrack": "Projektstunden überwachen",
|
||||||
|
"useCaseOther": "Etwas anderes",
|
||||||
|
"selectedText": "ausgewählt",
|
||||||
|
"previousToolsQuestion": "Welche Tools haben Sie zuvor verwendet? (Optional)",
|
||||||
|
"previousToolsPlaceholder": "z.B. Asana, Trello, Jira, Monday.com, etc.",
|
||||||
|
|
||||||
|
"discoveryTitle": "Eine letzte Sache...",
|
||||||
|
"discoveryDescription": "Helfen Sie uns zu verstehen, wie Sie Worklenz entdeckt haben",
|
||||||
|
"discoveryQuestion": "Wie haben Sie von uns erfahren?",
|
||||||
|
"allSetTitle": "Sie sind bereit!",
|
||||||
|
"allSetDescription": "Lassen Sie uns Ihr erstes Projekt erstellen und mit Worklenz beginnen",
|
||||||
|
"aboutYouStepName": "Über Sie",
|
||||||
|
"yourNeedsStepName": "Ihre Bedürfnisse",
|
||||||
|
"discoveryStepName": "Entdeckung",
|
||||||
|
"stepProgress": "Schritt {step} von 3: {title}",
|
||||||
|
|
||||||
|
"projectStepHeader": "Lassen Sie uns Ihr erstes Projekt erstellen",
|
||||||
|
"projectStepSubheader": "Von Grund auf beginnen oder eine Vorlage verwenden, um schneller voranzukommen",
|
||||||
|
"startFromScratch": "Von Grund auf beginnen",
|
||||||
|
"templateSelected": "Vorlage unten ausgewählt",
|
||||||
|
"quickSuggestions": "Schnelle Vorschläge:",
|
||||||
|
"orText": "ODER",
|
||||||
|
"startWithTemplate": "Mit einer Vorlage beginnen",
|
||||||
|
"clearToSelectTemplate": "Projektname oben löschen, um eine Vorlage auszuwählen",
|
||||||
|
"templateHeadStart": "Verschaffen Sie sich einen Vorsprung mit vorgefertigten Projektstrukturen",
|
||||||
|
"browseAllTemplates": "Alle Vorlagen durchsuchen",
|
||||||
|
"templatesAvailable": "15+ branchenspezifische Vorlagen verfügbar",
|
||||||
|
"chooseTemplate": "Wählen Sie eine Vorlage, die zu Ihrem Projekttyp passt",
|
||||||
|
"createProject": "Projekt erstellen",
|
||||||
|
|
||||||
|
"templateSoftwareDev": "Softwareentwicklung",
|
||||||
|
"templateSoftwareDesc": "Agile Sprints, Fehlerverfolgung, Releases",
|
||||||
|
"templateMarketing": "Marketing-Kampagne",
|
||||||
|
"templateMarketingDesc": "Kampagnenplanung, Content-Kalender",
|
||||||
|
"templateConstruction": "Bauprojekt",
|
||||||
|
"templateConstructionDesc": "Phasen, Genehmigungen, Auftragnehmer",
|
||||||
|
"templateStartup": "Startup-Launch",
|
||||||
|
"templateStartupDesc": "MVP-Entwicklung, Finanzierung, Wachstum",
|
||||||
|
|
||||||
|
"tasksStepTitle": "Fügen Sie Ihre ersten Aufgaben hinzu",
|
||||||
|
"tasksStepDescription": "Unterteilen Sie \"{{projectName}}\" in umsetzbare Aufgaben, um zu beginnen",
|
||||||
|
"taskPlaceholder": "Aufgabe {{index}} - z.B., Was muss getan werden?",
|
||||||
|
"addAnotherTask": "Weitere Aufgabe hinzufügen ({{current}}/{{max}})",
|
||||||
|
|
||||||
|
"surveyStepTitle": "Erzählen Sie uns von sich",
|
||||||
|
"surveyStepLabel": "Helfen Sie uns, Ihre Worklenz-Erfahrung zu personalisieren, indem Sie ein paar Fragen beantworten.",
|
||||||
|
|
||||||
|
"organizationType": "Was beschreibt Ihre Organisation am besten?",
|
||||||
|
"organizationTypeFreelancer": "Freelancer",
|
||||||
|
"organizationTypeStartup": "Startup",
|
||||||
|
"organizationTypeSmallMediumBusiness": "Kleines oder mittleres Unternehmen",
|
||||||
|
"organizationTypeAgency": "Agentur",
|
||||||
|
"organizationTypeEnterprise": "Unternehmen",
|
||||||
|
"organizationTypeOther": "Andere",
|
||||||
|
|
||||||
|
"userRole": "Was ist Ihre Rolle?",
|
||||||
|
"userRoleFounderCeo": "Gründer / CEO",
|
||||||
|
"userRoleProjectManager": "Projektmanager",
|
||||||
|
"userRoleSoftwareDeveloper": "Software-Entwickler",
|
||||||
|
"userRoleDesigner": "Designer",
|
||||||
|
"userRoleOperations": "Betrieb",
|
||||||
|
"userRoleOther": "Andere",
|
||||||
|
|
||||||
|
"mainUseCases": "Wofür werden Sie Worklenz hauptsächlich verwenden?",
|
||||||
|
"mainUseCasesTaskManagement": "Aufgabenverwaltung",
|
||||||
|
"mainUseCasesTeamCollaboration": "Teamzusammenarbeit",
|
||||||
|
"mainUseCasesResourcePlanning": "Ressourcenplanung",
|
||||||
|
"mainUseCasesClientCommunication": "Kundenkommunikation & Berichterstattung",
|
||||||
|
"mainUseCasesTimeTracking": "Zeiterfassung",
|
||||||
|
"mainUseCasesOther": "Andere",
|
||||||
|
|
||||||
|
"previousTools": "Welche Tools haben Sie vor Worklenz verwendet?",
|
||||||
|
"previousToolsPlaceholder": "z.B. Trello, Asana, Monday.com",
|
||||||
|
|
||||||
|
"howHeardAbout": "Wie haben Sie von Worklenz erfahren?",
|
||||||
|
"howHeardAboutGoogleSearch": "Google-Suche",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "Ein Freund oder Kollege",
|
||||||
|
"howHeardAboutBlogArticle": "Ein Blog oder Artikel",
|
||||||
|
"howHeardAboutOther": "Andere",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "Erzählen Sie uns von sich",
|
||||||
|
"aboutYouStepDescription": "Helfen Sie uns, Ihre Erfahrung zu personalisieren",
|
||||||
|
"yourNeedsStepTitle": "Was sind Ihre Hauptbedürfnisse?",
|
||||||
|
"yourNeedsStepDescription": "Wählen Sie alle zutreffenden aus, um uns bei der Einrichtung Ihres Arbeitsbereichs zu helfen",
|
||||||
|
"selected": "ausgewählt",
|
||||||
|
"previousToolsLabel": "Welche Tools haben Sie zuvor verwendet? (Optional)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX, Grafiken, Kreativ",
|
||||||
|
"developer": "Frontend, Backend, Full-stack",
|
||||||
|
"projectManager": "Planung, Koordination",
|
||||||
|
"marketing": "Inhalt, Social Media, Wachstum",
|
||||||
|
"sales": "Geschäftsentwicklung, Kundenbeziehungen",
|
||||||
|
"operations": "Admin, HR, Finanzen"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"pt": "Português",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "简体中文"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["Kundenprojekt", "Portfolio-Update", "Persönliche Marke"],
|
||||||
|
"startup": ["MVP-Entwicklung", "Produktlaunch", "Marktforschung"],
|
||||||
|
"agency": ["Kundenkampagne", "Markenstrategie", "Website-Redesign"],
|
||||||
|
"enterprise": ["Systemumstellung", "Prozessoptimierung", "Teamschulung"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "Aufgaben organisieren und verfolgen",
|
||||||
|
"teamCollaboration": "Nahtlos zusammenarbeiten",
|
||||||
|
"resourcePlanning": "Zeit und Ressourcen verwalten",
|
||||||
|
"clientCommunication": "Mit Kunden in Verbindung bleiben",
|
||||||
|
"timeTracking": "Projektstunden überwachen",
|
||||||
|
"other": "Etwas anderes"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"billingDetails": "Abrechnungsdetails",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Name",
|
||||||
|
"emailAddress": "E-Mail-Adresse",
|
||||||
|
"emailPlaceholder": "E-Mail-Adresse",
|
||||||
|
"contactNumber": "Telefonnummer",
|
||||||
|
"phoneNumberPlaceholder": "Telefonnummer",
|
||||||
|
"phoneValidationError": "Telefonnummer muss genau 10 Ziffern haben",
|
||||||
|
"companyDetails": "Firmendetails",
|
||||||
|
"companyName": "Firmenname",
|
||||||
|
"companyNamePlaceholder": "Firmenname",
|
||||||
|
"addressLine01": "Adresszeile 01",
|
||||||
|
"addressLine01Placeholder": "Adresszeile 01",
|
||||||
|
"addressLine02": "Adresszeile 02",
|
||||||
|
"addressLine02Placeholder": "Adresszeile 02",
|
||||||
|
"country": "Land",
|
||||||
|
"countryPlaceholder": "Land",
|
||||||
|
"city": "Stadt",
|
||||||
|
"cityPlaceholder": "Stadt",
|
||||||
|
"state": "Bundesland",
|
||||||
|
"statePlaceholder": "Bundesland",
|
||||||
|
"postalCode": "Postleitzahl",
|
||||||
|
"postalCodePlaceholder": "Postleitzahl",
|
||||||
|
"save": "Speichern"
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
"deleteConfirmationOk": "Ja",
|
"deleteConfirmationOk": "Ja",
|
||||||
"deleteConfirmationCancel": "Abbrechen",
|
"deleteConfirmationCancel": "Abbrechen",
|
||||||
|
|
||||||
|
"deleteTaskTitle": "Aufgabe löschen",
|
||||||
|
"deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleteTaskConfirm": "Löschen",
|
||||||
|
"deleteTaskCancel": "Abbrechen",
|
||||||
|
|
||||||
|
"deleteStatusTitle": "Status löschen",
|
||||||
|
"deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
|
||||||
|
"deletePhaseTitle": "Phase löschen",
|
||||||
|
"deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
|
||||||
"dueDate": "Fälligkeitsdatum",
|
"dueDate": "Fälligkeitsdatum",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
|
|
||||||
@@ -26,5 +37,17 @@
|
|||||||
"noDueDate": "Kein Fälligkeitsdatum",
|
"noDueDate": "Kein Fälligkeitsdatum",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"clear": "Löschen",
|
"clear": "Löschen",
|
||||||
"nextWeek": "Nächste Woche"
|
"nextWeek": "Nächste Woche",
|
||||||
|
"noSubtasks": "Keine Unteraufgaben",
|
||||||
|
"showSubtasks": "Unteraufgaben anzeigen",
|
||||||
|
"hideSubtasks": "Unteraufgaben ausblenden",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "Fehler beim Laden der Aufgaben",
|
||||||
|
"noTasksFound": "Keine Aufgaben gefunden",
|
||||||
|
"loadingFilters": "Filter werden geladen...",
|
||||||
|
"failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge",
|
||||||
|
"failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge",
|
||||||
|
"pleaseTryAgain": "Bitte versuchen Sie es erneut",
|
||||||
|
"taskNotCompleted": "Aufgabe ist nicht abgeschlossen",
|
||||||
|
"completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"deleteButtonTooltip": "Aus Projekt entfernen",
|
"deleteButtonTooltip": "Aus Projekt entfernen",
|
||||||
"memberCount": "Mitglied",
|
"memberCount": "Mitglied",
|
||||||
"membersCountPlural": "Mitglieder",
|
"membersCountPlural": "Mitglieder",
|
||||||
"emptyText": "Es gibt keine Anhänge in diesem Projekt."
|
"emptyText": "Es gibt keine Anhänge in diesem Projekt.",
|
||||||
|
"searchPlaceholder": "Mitglieder suchen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,9 @@
|
|||||||
"searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail",
|
"searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail",
|
||||||
"searchPlaceholder": "Name oder E-Mail eingeben",
|
"searchPlaceholder": "Name oder E-Mail eingeben",
|
||||||
"inviteAsAMember": "Als Mitglied einladen",
|
"inviteAsAMember": "Als Mitglied einladen",
|
||||||
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen"
|
"inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen",
|
||||||
|
"members": "Mitglieder",
|
||||||
|
"copyProjectLink": "Projektlink kopieren",
|
||||||
|
"inviteMember": "Mitglied einladen",
|
||||||
|
"alsoInviteToProject": "Auch zum Projekt einladen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Jobtitel",
|
"jobTitleLabel": "Jobtitel",
|
||||||
"jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)",
|
"jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)",
|
||||||
"memberAccessLabel": "Zugriffslevel",
|
"memberAccessLabel": "Zugriffslevel",
|
||||||
"addToTeamButton": "Mitglied zum Team hinzufügen",
|
"addToTeamButton": "Einladung senden",
|
||||||
"updateButton": "Änderungen speichern",
|
"updateButton": "Änderungen speichern",
|
||||||
"resendInvitationButton": "Einladungs-E-Mail erneut senden",
|
"resendInvitationButton": "Einladungs-E-Mail erneut senden",
|
||||||
"invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!",
|
"invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "Aktualisiert",
|
"updatedText": "Aktualisiert",
|
||||||
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
|
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
|
||||||
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
|
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
|
||||||
"invitationResent": "Einladung erfolgreich erneut gesendet!"
|
"invitationResent": "Einladung erfolgreich erneut gesendet!",
|
||||||
|
"copyTeamLink": "Team-Link kopieren"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
{
|
{
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
|
|
||||||
"setupYourAccount": "Setup Your Worklenz Account.",
|
"setupYourAccount": "Setup Your Account.",
|
||||||
"organizationStepTitle": "Name Your Organization",
|
"organizationStepTitle": "Name Your Organization",
|
||||||
"organizationStepLabel": "Pick a name for your Worklenz account.",
|
"organizationStepWelcome": "Welcome to Worklenz!",
|
||||||
|
"organizationStepDescription": "Let's start by setting up your organization. This will be the main workspace for your team.",
|
||||||
|
"organizationStepLabel": "Organization name",
|
||||||
|
"organizationStepPlaceholder": "e.g. Acme Corporation",
|
||||||
|
"organizationStepTooltip": "This name will appear in your workspace and can be changed later in settings.",
|
||||||
|
"organizationStepNeedIdeas": "Need ideas?",
|
||||||
|
"organizationStepUseDetected": "Use detected:",
|
||||||
|
"organizationStepCharacters": "characters",
|
||||||
|
"organizationStepGoodLength": "Good length",
|
||||||
|
"organizationStepTooShort": "Too short",
|
||||||
|
"organizationStepNamingTips": "Naming Tips",
|
||||||
|
"organizationStepTip1": "Keep it simple and memorable",
|
||||||
|
"organizationStepTip2": "Reflect your industry or values",
|
||||||
|
"organizationStepTip3": "Think about future growth",
|
||||||
|
"organizationStepTip4": "Make it unique and brandable",
|
||||||
|
"organizationStepSuggestionsTitle": "Name Suggestions",
|
||||||
|
"organizationStepCategory1": "Tech Companies",
|
||||||
|
"organizationStepCategory2": "Creative Agencies",
|
||||||
|
"organizationStepCategory3": "Consulting",
|
||||||
|
"organizationStepCategory4": "Startups",
|
||||||
|
"organizationStepSuggestionsNote": "These are just examples to get you started. Choose something that represents your organization.",
|
||||||
|
"organizationStepPrivacyNote": "Your organization name is private and only visible to your team members.",
|
||||||
|
|
||||||
"projectStepTitle": "Create your first project",
|
"projectStepTitle": "Create your first project",
|
||||||
"projectStepLabel": "What project are you working on right now?",
|
"projectStepLabel": "What project are you working on right now?",
|
||||||
"projectStepPlaceholder": "e.g. Marketing Plan",
|
"projectStepPlaceholder": "e.g. Marketing Plan",
|
||||||
|
|
||||||
"tasksStepTitle": "Create your first tasks",
|
|
||||||
"tasksStepLabel": "Type a few tasks that you are going to do in",
|
"tasksStepLabel": "Type a few tasks that you are going to do in",
|
||||||
"tasksStepAddAnother": "Add another",
|
"tasksStepAddAnother": "Add another",
|
||||||
|
|
||||||
@@ -27,5 +47,163 @@
|
|||||||
"formTitle": "Create your first task.",
|
"formTitle": "Create your first task.",
|
||||||
"step3Title": "Invite your team to work with",
|
"step3Title": "Invite your team to work with",
|
||||||
"maxMembers": " (You can invite up to 5 members)",
|
"maxMembers": " (You can invite up to 5 members)",
|
||||||
"maxTasks": " (You can create up to 5 tasks)"
|
"maxTasks": " (You can create up to 5 tasks)",
|
||||||
|
|
||||||
|
"membersStepTitle": "Invite your team",
|
||||||
|
"membersStepDescription": "Add team members to \"{{organizationName}}\" and start collaborating",
|
||||||
|
"memberPlaceholder": "Team member {{index}} - Enter email address",
|
||||||
|
"validEmailAddress": "Valid email address",
|
||||||
|
"addAnotherTeamMember": "Add another team member ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "You can always invite team members later",
|
||||||
|
"skipStepDescription": "Don't have email addresses ready? No problem! You can skip this step and invite team members from your project dashboard later.",
|
||||||
|
|
||||||
|
"orgCategoryTech": "Tech Companies",
|
||||||
|
"orgCategoryCreative": "Creative Agencies",
|
||||||
|
"orgCategoryConsulting": "Consulting",
|
||||||
|
"orgCategoryStartups": "Startups",
|
||||||
|
"namingTip1": "Keep it simple and memorable",
|
||||||
|
"namingTip2": "Reflect your industry or values",
|
||||||
|
"namingTip3": "Think about future growth",
|
||||||
|
"namingTip4": "Make it unique and brandable",
|
||||||
|
|
||||||
|
"aboutYouTitle": "Tell us about yourself",
|
||||||
|
"aboutYouDescription": "Help us personalize your experience",
|
||||||
|
"orgTypeQuestion": "What best describes your organization?",
|
||||||
|
"userRoleQuestion": "What's your role?",
|
||||||
|
|
||||||
|
"yourNeedsTitle": "What are your main needs?",
|
||||||
|
"yourNeedsDescription": "Select all that apply to help us set up your workspace",
|
||||||
|
"yourNeedsQuestion": "How will you primarily use Worklenz?",
|
||||||
|
"useCaseTaskOrg": "Organize and track tasks",
|
||||||
|
"useCaseTeamCollab": "Work together seamlessly",
|
||||||
|
"useCaseResourceMgmt": "Manage time and resources",
|
||||||
|
"useCaseClientComm": "Stay connected with clients",
|
||||||
|
"useCaseTimeTrack": "Monitor project hours",
|
||||||
|
"useCaseOther": "Something else",
|
||||||
|
"selectedText": "selected",
|
||||||
|
"previousToolsQuestion": "What tools have you used before? (Optional)",
|
||||||
|
|
||||||
|
"discoveryTitle": "One last thing...",
|
||||||
|
"discoveryDescription": "Help us understand how you discovered Worklenz",
|
||||||
|
"discoveryQuestion": "How did you hear about us?",
|
||||||
|
"allSetTitle": "You're all set!",
|
||||||
|
"allSetDescription": "Let's create your first project and get started with Worklenz",
|
||||||
|
"aboutYouStepName": "About You",
|
||||||
|
"yourNeedsStepName": "Your Needs",
|
||||||
|
"discoveryStepName": "Discovery",
|
||||||
|
"stepProgress": "Step {step} of 3: {title}",
|
||||||
|
|
||||||
|
"projectStepHeader": "Let's create your first project",
|
||||||
|
"projectStepSubheader": "Start from scratch or use a template to get going faster",
|
||||||
|
"startFromScratch": "Start from scratch",
|
||||||
|
"templateSelected": "Template selected below",
|
||||||
|
"quickSuggestions": "Quick suggestions:",
|
||||||
|
"orText": "OR",
|
||||||
|
"startWithTemplate": "Start with a template",
|
||||||
|
"clearToSelectTemplate": "Clear project name above to select a template",
|
||||||
|
"templateHeadStart": "Get a head start with pre-built project structures",
|
||||||
|
"browseAllTemplates": "Browse All Templates",
|
||||||
|
"templatesAvailable": "15+ industry-specific templates available",
|
||||||
|
"chooseTemplate": "Choose a template that matches your project type",
|
||||||
|
"createProject": "Create Project",
|
||||||
|
|
||||||
|
"templateSoftwareDev": "Software Development",
|
||||||
|
"templateSoftwareDesc": "Agile sprints, bug tracking, releases",
|
||||||
|
"templateMarketing": "Marketing Campaign",
|
||||||
|
"templateMarketingDesc": "Campaign planning, content calendar",
|
||||||
|
"templateConstruction": "Construction Project",
|
||||||
|
"templateConstructionDesc": "Phases, permits, contractors",
|
||||||
|
"templateStartup": "Startup Launch",
|
||||||
|
"templateStartupDesc": "MVP development, funding, growth",
|
||||||
|
|
||||||
|
"tasksStepTitle": "Add your first tasks",
|
||||||
|
"tasksStepDescription": "Break down \"{{projectName}}\" into actionable tasks to get started",
|
||||||
|
"taskPlaceholder": "Task {{index}} - e.g., What needs to be done?",
|
||||||
|
"addAnotherTask": "Add another task ({{current}}/{{max}})",
|
||||||
|
|
||||||
|
"surveyStepTitle": "Tell us about yourself",
|
||||||
|
"surveyStepLabel": "Help us personalize your Worklenz experience by answering a few questions.",
|
||||||
|
|
||||||
|
"organizationType": "What best describes your organization?",
|
||||||
|
"organizationTypeFreelancer": "Freelancer",
|
||||||
|
"organizationTypeStartup": "Startup",
|
||||||
|
"organizationTypeSmallMediumBusiness": "Small or Medium Business",
|
||||||
|
"organizationTypeAgency": "Agency",
|
||||||
|
"organizationTypeEnterprise": "Enterprise",
|
||||||
|
"organizationTypeOther": "Other",
|
||||||
|
|
||||||
|
"userRole": "What is your role?",
|
||||||
|
"userRoleFounderCeo": "Founder / CEO",
|
||||||
|
"userRoleProjectManager": "Project Manager",
|
||||||
|
"userRoleSoftwareDeveloper": "Software Developer",
|
||||||
|
"userRoleDesigner": "Designer",
|
||||||
|
"userRoleOperations": "Operations",
|
||||||
|
"userRoleOther": "Other",
|
||||||
|
|
||||||
|
"mainUseCases": "What will you mainly use Worklenz for?",
|
||||||
|
"mainUseCasesTaskManagement": "Task management",
|
||||||
|
"mainUseCasesTeamCollaboration": "Team collaboration",
|
||||||
|
"mainUseCasesResourcePlanning": "Resource planning",
|
||||||
|
"mainUseCasesClientCommunication": "Client communication & reporting",
|
||||||
|
"mainUseCasesTimeTracking": "Time tracking",
|
||||||
|
"mainUseCasesOther": "Other",
|
||||||
|
|
||||||
|
"previousTools": "What tool(s) were you using before Worklenz?",
|
||||||
|
"previousToolsPlaceholder": "e.g. Trello, Asana, Monday.com",
|
||||||
|
|
||||||
|
"howHeardAbout": "How did you hear about Worklenz?",
|
||||||
|
"howHeardAboutGoogleSearch": "Google Search",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "A friend or colleague",
|
||||||
|
"howHeardAboutBlogArticle": "A blog or article",
|
||||||
|
"howHeardAboutOther": "Other",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "Tell us about yourself",
|
||||||
|
"aboutYouStepDescription": "Help us personalize your experience",
|
||||||
|
"yourNeedsStepTitle": "What are your main needs?",
|
||||||
|
"yourNeedsStepDescription": "Select all that apply to help us set up your workspace",
|
||||||
|
"selected": "selected",
|
||||||
|
"previousToolsLabel": "What tools have you used before? (Optional)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX, Graphics, Creative",
|
||||||
|
"developer": "Frontend, Backend, Full-stack",
|
||||||
|
"projectManager": "Planning, Coordination",
|
||||||
|
"marketing": "Content, Social Media, Growth",
|
||||||
|
"sales": "Business Development, Client Relations",
|
||||||
|
"operations": "Admin, HR, Finance"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"pt": "Português",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "简体中文"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["Client Project", "Portfolio Update", "Personal Brand"],
|
||||||
|
"startup": ["MVP Development", "Product Launch", "Market Research"],
|
||||||
|
"agency": ["Client Campaign", "Brand Strategy", "Website Redesign"],
|
||||||
|
"enterprise": ["System Migration", "Process Optimization", "Team Training"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "Organize and track tasks",
|
||||||
|
"teamCollaboration": "Work together seamlessly",
|
||||||
|
"resourcePlanning": "Manage time and resources",
|
||||||
|
"clientCommunication": "Stay connected with clients",
|
||||||
|
"timeTracking": "Monitor project hours",
|
||||||
|
"other": "Something else"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"billingDetails": "Billing Details",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Name",
|
||||||
|
"emailAddress": "Email Address",
|
||||||
|
"emailPlaceholder": "Email Address",
|
||||||
|
"contactNumber": "Contact Number",
|
||||||
|
"phoneNumberPlaceholder": "Phone Number",
|
||||||
|
"phoneValidationError": "Phone number must be exactly 10 digits",
|
||||||
|
"companyDetails": "Company Details",
|
||||||
|
"companyName": "Company Name",
|
||||||
|
"companyNamePlaceholder": "Company Name",
|
||||||
|
"addressLine01": "Address Line 01",
|
||||||
|
"addressLine01Placeholder": "Address Line 01",
|
||||||
|
"addressLine02": "Address Line 02",
|
||||||
|
"addressLine02Placeholder": "Address Line 02",
|
||||||
|
"country": "Country",
|
||||||
|
"countryPlaceholder": "Country",
|
||||||
|
"city": "City",
|
||||||
|
"cityPlaceholder": "City",
|
||||||
|
"state": "State",
|
||||||
|
"statePlaceholder": "State",
|
||||||
|
"postalCode": "Postal Code",
|
||||||
|
"postalCodePlaceholder": "Postal Code",
|
||||||
|
"save": "Save"
|
||||||
|
}
|
||||||
@@ -117,5 +117,26 @@
|
|||||||
"currentSeatsText": "You currently have {{seats}} seats available.",
|
"currentSeatsText": "You currently have {{seats}} seats available.",
|
||||||
"selectSeatsText": "Please select the number of additional seats to purchase.",
|
"selectSeatsText": "Please select the number of additional seats to purchase.",
|
||||||
"purchase": "Purchase",
|
"purchase": "Purchase",
|
||||||
"contactSales": "Contact sales"
|
"contactSales": "Contact sales",
|
||||||
|
"submitSuccess": "Code redeemed successfully!",
|
||||||
|
"submitSuccessDescription": "Your account has been updated with the new credits.",
|
||||||
|
"percentUsed": "% Used",
|
||||||
|
"sizeUnits": {
|
||||||
|
"bytes": "Bytes",
|
||||||
|
"kb": "KB",
|
||||||
|
"mb": "MB",
|
||||||
|
"gb": "GB",
|
||||||
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"seatPerMonth": "seat / month",
|
||||||
|
"totalPrice": "Total $",
|
||||||
|
"tryForFree": "Try for free",
|
||||||
|
"subscriptionUpdateSuccess": "Subscription updated successfully!",
|
||||||
|
"paymentProcessorError": "Failed to load payment processor",
|
||||||
|
"seatsLabel": "Seats:",
|
||||||
|
"requiredField": "*",
|
||||||
|
"purchaseSeatsTextSingle": "To continue, you'll need to purchase an additional seat.",
|
||||||
|
"singleUserNote": "You currently have 1 seat available.",
|
||||||
|
"selectSeatsTextSingle": "Please select the number of additional seats to purchase.",
|
||||||
|
"phoneNumberPattern": "07xxxxxxxx"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,8 @@
|
|||||||
"owner": "Organization Owner",
|
"owner": "Organization Owner",
|
||||||
"admins": "Organization Admins",
|
"admins": "Organization Admins",
|
||||||
"contactNumber": "Add Contact Number",
|
"contactNumber": "Add Contact Number",
|
||||||
"edit": "Edit"
|
"edit": "Edit",
|
||||||
|
"emailAddress": "Email Address",
|
||||||
|
"enterOrganizationName": "Enter organization name",
|
||||||
|
"ownerSuffix": " (Owner)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
"user": "User",
|
"user": "User",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"lastActivity": "Last Activity",
|
"lastActivity": "Last Activity",
|
||||||
"refresh": "Refresh users"
|
"refresh": "Refresh users",
|
||||||
|
"name": "Name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
"deleteConfirmationOk": "Yes",
|
"deleteConfirmationOk": "Yes",
|
||||||
"deleteConfirmationCancel": "Cancel",
|
"deleteConfirmationCancel": "Cancel",
|
||||||
|
|
||||||
|
"deleteTaskTitle": "Delete Task",
|
||||||
|
"deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.",
|
||||||
|
"deleteTaskConfirm": "Delete",
|
||||||
|
"deleteTaskCancel": "Cancel",
|
||||||
|
|
||||||
|
"deleteStatusTitle": "Delete Status",
|
||||||
|
"deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.",
|
||||||
|
|
||||||
|
"deletePhaseTitle": "Delete Phase",
|
||||||
|
"deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.",
|
||||||
|
|
||||||
"dueDate": "Due date",
|
"dueDate": "Due date",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|
||||||
@@ -29,5 +40,14 @@
|
|||||||
"nextWeek": "Next week",
|
"nextWeek": "Next week",
|
||||||
"noSubtasks": "No subtasks",
|
"noSubtasks": "No subtasks",
|
||||||
"showSubtasks": "Show subtasks",
|
"showSubtasks": "Show subtasks",
|
||||||
"hideSubtasks": "Hide subtasks"
|
"hideSubtasks": "Hide subtasks",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "Error loading tasks",
|
||||||
|
"noTasksFound": "No tasks found",
|
||||||
|
"loadingFilters": "Loading filters...",
|
||||||
|
"failedToUpdateColumnOrder": "Failed to update column order",
|
||||||
|
"failedToUpdatePhaseOrder": "Failed to update phase order",
|
||||||
|
"pleaseTryAgain": "Please try again",
|
||||||
|
"taskNotCompleted": "Task is not completed",
|
||||||
|
"completeTaskDependencies": "Please complete the task dependencies before proceeding"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"deleteButtonTooltip": "Remove from project",
|
"deleteButtonTooltip": "Remove from project",
|
||||||
"memberCount": "Member",
|
"memberCount": "Member",
|
||||||
"membersCountPlural": "Members",
|
"membersCountPlural": "Members",
|
||||||
"emptyText": "There are no attachments in the project."
|
"emptyText": "There are no attachments in the project.",
|
||||||
|
"searchPlaceholder": "Search members"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"title": "Project Members",
|
"title": "Share Project",
|
||||||
"searchLabel": "Add members by adding their name or email",
|
"searchLabel": "Add members by adding their name or email",
|
||||||
"searchPlaceholder": "Type name or email",
|
"searchPlaceholder": "Type name or email",
|
||||||
"inviteAsAMember": "Invite as a member",
|
"inviteAsAMember": "Invite as a member",
|
||||||
"inviteNewMemberByEmail": "Invite new member by email"
|
"inviteNewMemberByEmail": "Invite new member by email",
|
||||||
|
"members": "Members",
|
||||||
|
"copyProjectLink": "Copy project link",
|
||||||
|
"inviteMember": "Invite Member",
|
||||||
|
"alsoInviteToProject": "Also invite to project"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"cancelText": "No, cancel",
|
"cancelText": "No, cancel",
|
||||||
"deactivatedText": "(Currently deactivated)",
|
"deactivatedText": "(Currently deactivated)",
|
||||||
"pendingInvitationText": "(Invitation pending)",
|
"pendingInvitationText": "(Invitation pending)",
|
||||||
"addMemberDrawerTitle": "Add New Team Member",
|
"addMemberDrawerTitle": "Invite Team Members",
|
||||||
"updateMemberDrawerTitle": "Update Team Member",
|
"updateMemberDrawerTitle": "Update Team Member",
|
||||||
"addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status",
|
"addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status",
|
||||||
"memberEmailLabel": "Email(s)",
|
"memberEmailLabel": "Email(s)",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Job Title",
|
"jobTitleLabel": "Job Title",
|
||||||
"jobTitlePlaceholder": "Select or search job title (Optional)",
|
"jobTitlePlaceholder": "Select or search job title (Optional)",
|
||||||
"memberAccessLabel": "Access Level",
|
"memberAccessLabel": "Access Level",
|
||||||
"addToTeamButton": "Add Member to Team",
|
"addToTeamButton": "Send Invitation",
|
||||||
"updateButton": "Save Changes",
|
"updateButton": "Save Changes",
|
||||||
"resendInvitationButton": "Resend Invitation Email",
|
"resendInvitationButton": "Resend Invitation Email",
|
||||||
"invitationSentSuccessMessage": "Team invitation sent successfully!",
|
"invitationSentSuccessMessage": "Team invitation sent successfully!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "Updated",
|
"updatedText": "Updated",
|
||||||
"noResultFound": "Type an email address and hit enter...",
|
"noResultFound": "Type an email address and hit enter...",
|
||||||
"jobTitlesFetchError": "Failed to fetch job titles",
|
"jobTitlesFetchError": "Failed to fetch job titles",
|
||||||
"invitationResent": "Invitation resent successfully!"
|
"invitationResent": "Invitation resent successfully!",
|
||||||
|
"copyTeamLink": "Copy team link"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,28 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Configura tu cuenta.",
|
"setupYourAccount": "Configura tu cuenta.",
|
||||||
"organizationStepTitle": "Nombra tu organización",
|
"organizationStepTitle": "Nombra tu organización",
|
||||||
"organizationStepLabel": "Elige un nombre para tu cuenta de Worklenz.",
|
"organizationStepWelcome": "¡Bienvenido a Worklenz!",
|
||||||
|
"organizationStepDescription": "Comencemos configurando tu organización. Este será el espacio de trabajo principal para tu equipo.",
|
||||||
|
"organizationStepLabel": "Nombre de la organización",
|
||||||
|
"organizationStepPlaceholder": "ej. Corporación Acme",
|
||||||
|
"organizationStepTooltip": "Este nombre aparecerá en tu espacio de trabajo y se puede cambiar más tarde en la configuración.",
|
||||||
|
"organizationStepNeedIdeas": "¿Necesitas ideas?",
|
||||||
|
"organizationStepUseDetected": "Usar detectado:",
|
||||||
|
"organizationStepCharacters": "caracteres",
|
||||||
|
"organizationStepGoodLength": "Buena longitud",
|
||||||
|
"organizationStepTooShort": "Demasiado corto",
|
||||||
|
"organizationStepNamingTips": "Consejos para nombrar",
|
||||||
|
"organizationStepTip1": "Manténlo simple y memorable",
|
||||||
|
"organizationStepTip2": "Refleja tu industria o valores",
|
||||||
|
"organizationStepTip3": "Piensa en el crecimiento futuro",
|
||||||
|
"organizationStepTip4": "Hazlo único y reconocible",
|
||||||
|
"organizationStepSuggestionsTitle": "Sugerencias de nombres",
|
||||||
|
"organizationStepCategory1": "Empresas tecnológicas",
|
||||||
|
"organizationStepCategory2": "Agencias creativas",
|
||||||
|
"organizationStepCategory3": "Consultoría",
|
||||||
|
"organizationStepCategory4": "Startups",
|
||||||
|
"organizationStepSuggestionsNote": "Estos son solo ejemplos para empezar. Elige algo que represente a tu organización.",
|
||||||
|
"organizationStepPrivacyNote": "El nombre de tu organización es privado y solo visible para los miembros de tu equipo.",
|
||||||
|
|
||||||
"projectStepTitle": "Crea tu primer proyecto",
|
"projectStepTitle": "Crea tu primer proyecto",
|
||||||
"projectStepLabel": "¿En qué proyecto estás trabajando ahora?",
|
"projectStepLabel": "¿En qué proyecto estás trabajando ahora?",
|
||||||
@@ -28,5 +49,164 @@
|
|||||||
"step3Title": "Invita a tu equipo a trabajar",
|
"step3Title": "Invita a tu equipo a trabajar",
|
||||||
|
|
||||||
"maxMembers": " (Puedes invitar hasta 5 miembros)",
|
"maxMembers": " (Puedes invitar hasta 5 miembros)",
|
||||||
"maxTasks": " (Puedes crear hasta 5 tareas)"
|
"maxTasks": " (Puedes crear hasta 5 tareas)",
|
||||||
|
|
||||||
|
"membersStepTitle": "Invita a tu equipo",
|
||||||
|
"membersStepDescription": "Añade miembros del equipo a \"{{organizationName}}\" y comienza a colaborar",
|
||||||
|
"memberPlaceholder": "Miembro del equipo {{index}} - Ingresa dirección de correo",
|
||||||
|
"validEmailAddress": "Dirección de correo válida",
|
||||||
|
"addAnotherTeamMember": "Añadir otro miembro del equipo ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "Siempre puedes invitar miembros del equipo más tarde",
|
||||||
|
"skipStepDescription": "¿No tienes direcciones de correo listas? ¡No hay problema! Puedes omitir este paso e invitar miembros del equipo desde tu panel de proyecto más tarde.",
|
||||||
|
|
||||||
|
"orgCategoryTech": "Empresas Tecnológicas",
|
||||||
|
"orgCategoryCreative": "Agencias Creativas",
|
||||||
|
"orgCategoryConsulting": "Consultoría",
|
||||||
|
"orgCategoryStartups": "Startups",
|
||||||
|
"namingTip1": "Manténlo simple y memorable",
|
||||||
|
"namingTip2": "Refleja tu industria o valores",
|
||||||
|
"namingTip3": "Piensa en el crecimiento futuro",
|
||||||
|
"namingTip4": "Hazlo único y reconocible",
|
||||||
|
|
||||||
|
"aboutYouTitle": "Cuéntanos sobre ti",
|
||||||
|
"aboutYouDescription": "Ayúdanos a personalizar tu experiencia",
|
||||||
|
"orgTypeQuestion": "¿Qué describe mejor tu organización?",
|
||||||
|
"userRoleQuestion": "¿Cuál es tu rol?",
|
||||||
|
|
||||||
|
"yourNeedsTitle": "¿Cuáles son tus principales necesidades?",
|
||||||
|
"yourNeedsDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
|
||||||
|
"yourNeedsQuestion": "¿Cómo usarás principalmente Worklenz?",
|
||||||
|
"useCaseTaskOrg": "Organizar y hacer seguimiento de tareas",
|
||||||
|
"useCaseTeamCollab": "Trabajar juntos sin problemas",
|
||||||
|
"useCaseResourceMgmt": "Gestionar tiempo y recursos",
|
||||||
|
"useCaseClientComm": "Mantenerse conectado con clientes",
|
||||||
|
"useCaseTimeTrack": "Monitorear horas de proyecto",
|
||||||
|
"useCaseOther": "Algo más",
|
||||||
|
"selectedText": "seleccionado",
|
||||||
|
"previousToolsQuestion": "¿Qué herramientas has usado antes? (Opcional)",
|
||||||
|
"previousToolsPlaceholder": "ej., Asana, Trello, Jira, Monday.com, etc.",
|
||||||
|
|
||||||
|
"discoveryTitle": "Una última cosa...",
|
||||||
|
"discoveryDescription": "Ayúdanos a entender cómo descubriste Worklenz",
|
||||||
|
"discoveryQuestion": "¿Cómo te enteraste de nosotros?",
|
||||||
|
"allSetTitle": "¡Ya estás listo!",
|
||||||
|
"allSetDescription": "Vamos a crear tu primer proyecto y comenzar con Worklenz",
|
||||||
|
"aboutYouStepName": "Sobre ti",
|
||||||
|
"yourNeedsStepName": "Tus necesidades",
|
||||||
|
"discoveryStepName": "Descubrimiento",
|
||||||
|
"stepProgress": "Paso {step} de 3: {title}",
|
||||||
|
|
||||||
|
"projectStepHeader": "Vamos a crear tu primer proyecto",
|
||||||
|
"projectStepSubheader": "Empieza desde cero o usa una plantilla para ir más rápido",
|
||||||
|
"startFromScratch": "Empezar desde cero",
|
||||||
|
"templateSelected": "Plantilla seleccionada abajo",
|
||||||
|
"quickSuggestions": "Sugerencias rápidas:",
|
||||||
|
"orText": "O",
|
||||||
|
"startWithTemplate": "Comenzar con una plantilla",
|
||||||
|
"clearToSelectTemplate": "Borra el nombre del proyecto arriba para seleccionar una plantilla",
|
||||||
|
"templateHeadStart": "Obtén una ventaja inicial con estructuras de proyecto pre-construidas",
|
||||||
|
"browseAllTemplates": "Explorar todas las plantillas",
|
||||||
|
"templatesAvailable": "15+ plantillas específicas de industria disponibles",
|
||||||
|
"chooseTemplate": "Elige una plantilla que coincida con tu tipo de proyecto",
|
||||||
|
"createProject": "Crear proyecto",
|
||||||
|
|
||||||
|
"templateSoftwareDev": "Desarrollo de Software",
|
||||||
|
"templateSoftwareDesc": "Sprints ágiles, seguimiento de errores, lanzamientos",
|
||||||
|
"templateMarketing": "Campaña de Marketing",
|
||||||
|
"templateMarketingDesc": "Planificación de campaña, calendario de contenido",
|
||||||
|
"templateConstruction": "Proyecto de Construcción",
|
||||||
|
"templateConstructionDesc": "Fases, permisos, contratistas",
|
||||||
|
"templateStartup": "Lanzamiento de Startup",
|
||||||
|
"templateStartupDesc": "Desarrollo MVP, financiación, crecimiento",
|
||||||
|
|
||||||
|
"tasksStepTitle": "Añade tus primeras tareas",
|
||||||
|
"tasksStepDescription": "Desglosa \"{{projectName}}\" en tareas accionables para comenzar",
|
||||||
|
"taskPlaceholder": "Tarea {{index}} - ej., ¿Qué necesita hacerse?",
|
||||||
|
"addAnotherTask": "Añadir otra tarea ({{current}}/{{max}})",
|
||||||
|
|
||||||
|
"surveyStepTitle": "Cuéntanos sobre ti",
|
||||||
|
"surveyStepLabel": "Ayúdanos a personalizar tu experiencia de Worklenz respondiendo algunas preguntas.",
|
||||||
|
|
||||||
|
"organizationType": "¿Qué describe mejor tu organización?",
|
||||||
|
"organizationTypeFreelancer": "Freelancer",
|
||||||
|
"organizationTypeStartup": "Startup",
|
||||||
|
"organizationTypeSmallMediumBusiness": "Pequeña o Mediana Empresa",
|
||||||
|
"organizationTypeAgency": "Agencia",
|
||||||
|
"organizationTypeEnterprise": "Empresa",
|
||||||
|
"organizationTypeOther": "Otro",
|
||||||
|
|
||||||
|
"userRole": "¿Cuál es tu rol?",
|
||||||
|
"userRoleFounderCeo": "Fundador / CEO",
|
||||||
|
"userRoleProjectManager": "Gerente de Proyecto",
|
||||||
|
"userRoleSoftwareDeveloper": "Desarrollador de Software",
|
||||||
|
"userRoleDesigner": "Diseñador",
|
||||||
|
"userRoleOperations": "Operaciones",
|
||||||
|
"userRoleOther": "Otro",
|
||||||
|
|
||||||
|
"mainUseCases": "¿Para qué usarás principalmente Worklenz?",
|
||||||
|
"mainUseCasesTaskManagement": "Gestión de tareas",
|
||||||
|
"mainUseCasesTeamCollaboration": "Colaboración de equipo",
|
||||||
|
"mainUseCasesResourcePlanning": "Planificación de recursos",
|
||||||
|
"mainUseCasesClientCommunication": "Comunicación con clientes e informes",
|
||||||
|
"mainUseCasesTimeTracking": "Seguimiento de tiempo",
|
||||||
|
"mainUseCasesOther": "Otro",
|
||||||
|
|
||||||
|
"previousTools": "¿Qué herramienta(s) usabas antes de Worklenz?",
|
||||||
|
"previousToolsPlaceholder": "ej. Trello, Asana, Monday.com",
|
||||||
|
|
||||||
|
"howHeardAbout": "¿Cómo conociste Worklenz?",
|
||||||
|
"howHeardAboutGoogleSearch": "Búsqueda de Google",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "Un amigo o colega",
|
||||||
|
"howHeardAboutBlogArticle": "Un blog o artículo",
|
||||||
|
"howHeardAboutOther": "Otro",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "Cuéntanos sobre ti",
|
||||||
|
"aboutYouStepDescription": "Ayúdanos a personalizar tu experiencia",
|
||||||
|
"yourNeedsStepTitle": "¿Cuáles son tus principales necesidades?",
|
||||||
|
"yourNeedsStepDescription": "Selecciona todas las que apliquen para ayudarnos a configurar tu espacio de trabajo",
|
||||||
|
"selected": "seleccionado",
|
||||||
|
"previousToolsLabel": "¿Qué herramientas has usado antes? (Opcional)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX, Gráficos, Creativo",
|
||||||
|
"developer": "Frontend, Backend, Full-stack",
|
||||||
|
"projectManager": "Planificación, Coordinación",
|
||||||
|
"marketing": "Contenido, Redes Sociales, Crecimiento",
|
||||||
|
"sales": "Desarrollo de Negocios, Relaciones con Clientes",
|
||||||
|
"operations": "Administración, RRHH, Finanzas"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"pt": "Português",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "简体中文"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["Proyecto Cliente", "Actualización Portfolio", "Marca Personal"],
|
||||||
|
"startup": ["Desarrollo MVP", "Lanzamiento Producto", "Investigación Mercado"],
|
||||||
|
"agency": ["Campaña Cliente", "Estrategia Marca", "Rediseño Website"],
|
||||||
|
"enterprise": ["Migración Sistema", "Optimización Procesos", "Capacitación Equipo"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "Organizar y rastrear tareas",
|
||||||
|
"teamCollaboration": "Trabajar juntos sin problemas",
|
||||||
|
"resourcePlanning": "Gestionar tiempo y recursos",
|
||||||
|
"clientCommunication": "Mantenerse conectado con clientes",
|
||||||
|
"timeTracking": "Monitorear horas de proyecto",
|
||||||
|
"other": "Algo más"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
"deleteConfirmationOk": "Sí",
|
"deleteConfirmationOk": "Sí",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
|
|
||||||
|
"deleteTaskTitle": "Eliminar tarea",
|
||||||
|
"deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.",
|
||||||
|
"deleteTaskConfirm": "Eliminar",
|
||||||
|
"deleteTaskCancel": "Cancelar",
|
||||||
|
|
||||||
|
"deleteStatusTitle": "Eliminar estado",
|
||||||
|
"deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.",
|
||||||
|
|
||||||
|
"deletePhaseTitle": "Eliminar fase",
|
||||||
|
"deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.",
|
||||||
|
|
||||||
"dueDate": "Fecha de vencimiento",
|
"dueDate": "Fecha de vencimiento",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
||||||
@@ -26,5 +37,17 @@
|
|||||||
"noDueDate": "Sin fecha de vencimiento",
|
"noDueDate": "Sin fecha de vencimiento",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"clear": "Limpiar",
|
"clear": "Limpiar",
|
||||||
"nextWeek": "Próxima semana"
|
"nextWeek": "Próxima semana",
|
||||||
|
"noSubtasks": "Sin subtareas",
|
||||||
|
"showSubtasks": "Mostrar subtareas",
|
||||||
|
"hideSubtasks": "Ocultar subtareas",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "Error al cargar tareas",
|
||||||
|
"noTasksFound": "No se encontraron tareas",
|
||||||
|
"loadingFilters": "Cargando filtros...",
|
||||||
|
"failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas",
|
||||||
|
"failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases",
|
||||||
|
"pleaseTryAgain": "Por favor, inténtalo de nuevo",
|
||||||
|
"taskNotCompleted": "La tarea no está completada",
|
||||||
|
"completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"deleteButtonTooltip": "Eliminar del proyecto",
|
"deleteButtonTooltip": "Eliminar del proyecto",
|
||||||
"memberCount": "Miembro",
|
"memberCount": "Miembro",
|
||||||
"membersCountPlural": "Miembros",
|
"membersCountPlural": "Miembros",
|
||||||
"emptyText": "No hay archivos adjuntos en el proyecto."
|
"emptyText": "No hay archivos adjuntos en el proyecto.",
|
||||||
|
"searchPlaceholder": "Buscar miembros"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,9 @@
|
|||||||
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
"searchLabel": "Agregar miembros ingresando su nombre o correo electrónico",
|
||||||
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
"searchPlaceholder": "Escriba nombre o correo electrónico",
|
||||||
"inviteAsAMember": "Invitar como miembro",
|
"inviteAsAMember": "Invitar como miembro",
|
||||||
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico"
|
"inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico",
|
||||||
|
"members": "Miembros",
|
||||||
|
"copyProjectLink": "Copiar enlace del proyecto",
|
||||||
|
"inviteMember": "Invitar miembro",
|
||||||
|
"alsoInviteToProject": "También invitar al proyecto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Cargo",
|
"jobTitleLabel": "Cargo",
|
||||||
"jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)",
|
"jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)",
|
||||||
"memberAccessLabel": "Nivel de acceso",
|
"memberAccessLabel": "Nivel de acceso",
|
||||||
"addToTeamButton": "Agregar miembro al equipo",
|
"addToTeamButton": "Enviar invitación",
|
||||||
"updateButton": "Guardar cambios",
|
"updateButton": "Guardar cambios",
|
||||||
"resendInvitationButton": "Reenviar correo de invitación",
|
"resendInvitationButton": "Reenviar correo de invitación",
|
||||||
"invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!",
|
"invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "Actualizado",
|
"updatedText": "Actualizado",
|
||||||
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
|
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
|
||||||
"jobTitlesFetchError": "Error al obtener los cargos",
|
"jobTitlesFetchError": "Error al obtener los cargos",
|
||||||
"invitationResent": "¡Invitación reenviada exitosamente!"
|
"invitationResent": "¡Invitación reenviada exitosamente!",
|
||||||
|
"copyTeamLink": "Copiar enlace del equipo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,28 @@
|
|||||||
|
|
||||||
"setupYourAccount": "Configure sua conta.",
|
"setupYourAccount": "Configure sua conta.",
|
||||||
"organizationStepTitle": "Nomeie sua organização",
|
"organizationStepTitle": "Nomeie sua organização",
|
||||||
"organizationStepLabel": "Escolha um nome para sua conta Worklenz.",
|
"organizationStepWelcome": "Bem-vindo ao Worklenz!",
|
||||||
|
"organizationStepDescription": "Vamos começar configurando sua organização. Este será o espaço de trabalho principal para sua equipe.",
|
||||||
|
"organizationStepLabel": "Nome da organização",
|
||||||
|
"organizationStepPlaceholder": "ex. Corporação Acme",
|
||||||
|
"organizationStepTooltip": "Este nome aparecerá em seu espaço de trabalho e pode ser alterado posteriormente nas configurações.",
|
||||||
|
"organizationStepNeedIdeas": "Precisa de ideias?",
|
||||||
|
"organizationStepUseDetected": "Usar detectado:",
|
||||||
|
"organizationStepCharacters": "caracteres",
|
||||||
|
"organizationStepGoodLength": "Bom comprimento",
|
||||||
|
"organizationStepTooShort": "Muito curto",
|
||||||
|
"organizationStepNamingTips": "Dicas de nomenclatura",
|
||||||
|
"organizationStepTip1": "Mantenha simples e memorável",
|
||||||
|
"organizationStepTip2": "Reflita sua indústria ou valores",
|
||||||
|
"organizationStepTip3": "Pense no crescimento futuro",
|
||||||
|
"organizationStepTip4": "Torne único e marcante",
|
||||||
|
"organizationStepSuggestionsTitle": "Sugestões de nomes",
|
||||||
|
"organizationStepCategory1": "Empresas de tecnologia",
|
||||||
|
"organizationStepCategory2": "Agências criativas",
|
||||||
|
"organizationStepCategory3": "Consultoria",
|
||||||
|
"organizationStepCategory4": "Startups",
|
||||||
|
"organizationStepSuggestionsNote": "Estes são apenas exemplos para começar. Escolha algo que represente sua organização.",
|
||||||
|
"organizationStepPrivacyNote": "O nome da sua organização é privado e visível apenas para os membros da sua equipe.",
|
||||||
|
|
||||||
"projectStepTitle": "Crie seu primeiro projeto",
|
"projectStepTitle": "Crie seu primeiro projeto",
|
||||||
"projectStepLabel": "Em qual projeto você está trabalhando agora?",
|
"projectStepLabel": "Em qual projeto você está trabalhando agora?",
|
||||||
@@ -28,5 +49,164 @@
|
|||||||
"step3Title": "Convide sua equipe para trabalhar",
|
"step3Title": "Convide sua equipe para trabalhar",
|
||||||
|
|
||||||
"maxMembers": " (Você pode convidar até 5 membros)",
|
"maxMembers": " (Você pode convidar até 5 membros)",
|
||||||
"maxTasks": " (Você pode criar até 5 tarefas)"
|
"maxTasks": " (Você pode criar até 5 tarefas)",
|
||||||
|
|
||||||
|
"membersStepTitle": "Convide sua equipe",
|
||||||
|
"membersStepDescription": "Adicione membros da equipe ao \"{{organizationName}}\" e comece a colaborar",
|
||||||
|
"memberPlaceholder": "Membro da equipe {{index}} - Digite o endereço de email",
|
||||||
|
"validEmailAddress": "Endereço de email válido",
|
||||||
|
"addAnotherTeamMember": "Adicionar outro membro da equipe ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "Você sempre pode convidar membros da equipe mais tarde",
|
||||||
|
"skipStepDescription": "Não tem endereços de email prontos? Sem problema! Você pode pular esta etapa e convidar membros da equipe do seu painel de projeto mais tarde.",
|
||||||
|
|
||||||
|
"orgCategoryTech": "Empresas de Tecnologia",
|
||||||
|
"orgCategoryCreative": "Agências Criativas",
|
||||||
|
"orgCategoryConsulting": "Consultoria",
|
||||||
|
"orgCategoryStartups": "Startups",
|
||||||
|
"namingTip1": "Mantenha simples e memorável",
|
||||||
|
"namingTip2": "Reflita sua indústria ou valores",
|
||||||
|
"namingTip3": "Pense no crescimento futuro",
|
||||||
|
"namingTip4": "Torne único e marcante",
|
||||||
|
|
||||||
|
"aboutYouTitle": "Conte-nos sobre você",
|
||||||
|
"aboutYouDescription": "Ajude-nos a personalizar sua experiência",
|
||||||
|
"orgTypeQuestion": "O que melhor descreve sua organização?",
|
||||||
|
"userRoleQuestion": "Qual é seu papel?",
|
||||||
|
|
||||||
|
"yourNeedsTitle": "Quais são suas principais necessidades?",
|
||||||
|
"yourNeedsDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
|
||||||
|
"yourNeedsQuestion": "Como você usará principalmente o Worklenz?",
|
||||||
|
"useCaseTaskOrg": "Organizar e acompanhar tarefas",
|
||||||
|
"useCaseTeamCollab": "Trabalhar juntos perfeitamente",
|
||||||
|
"useCaseResourceMgmt": "Gerenciar tempo e recursos",
|
||||||
|
"useCaseClientComm": "Manter-se conectado com clientes",
|
||||||
|
"useCaseTimeTrack": "Monitorar horas do projeto",
|
||||||
|
"useCaseOther": "Algo mais",
|
||||||
|
"selectedText": "selecionado",
|
||||||
|
"previousToolsQuestion": "Que ferramentas você usou antes? (Opcional)",
|
||||||
|
"previousToolsPlaceholder": "ex., Asana, Trello, Jira, Monday.com, etc.",
|
||||||
|
|
||||||
|
"discoveryTitle": "Uma última coisa...",
|
||||||
|
"discoveryDescription": "Ajude-nos a entender como você descobriu o Worklenz",
|
||||||
|
"discoveryQuestion": "Como você soube sobre nós?",
|
||||||
|
"allSetTitle": "Você está pronto!",
|
||||||
|
"allSetDescription": "Vamos criar seu primeiro projeto e começar com o Worklenz",
|
||||||
|
"aboutYouStepName": "Sobre você",
|
||||||
|
"yourNeedsStepName": "Suas necessidades",
|
||||||
|
"discoveryStepName": "Descoberta",
|
||||||
|
"stepProgress": "Passo {step} de 3: {title}",
|
||||||
|
|
||||||
|
"projectStepHeader": "Vamos criar seu primeiro projeto",
|
||||||
|
"projectStepSubheader": "Comece do zero ou use um modelo para ir mais rápido",
|
||||||
|
"startFromScratch": "Começar do zero",
|
||||||
|
"templateSelected": "Modelo selecionado abaixo",
|
||||||
|
"quickSuggestions": "Sugestões rápidas:",
|
||||||
|
"orText": "OU",
|
||||||
|
"startWithTemplate": "Começar com um modelo",
|
||||||
|
"clearToSelectTemplate": "Limpe o nome do projeto acima para selecionar um modelo",
|
||||||
|
"templateHeadStart": "Obtenha uma vantagem inicial com estruturas de projeto pré-construídas",
|
||||||
|
"browseAllTemplates": "Navegar por todos os modelos",
|
||||||
|
"templatesAvailable": "15+ modelos específicos da indústria disponíveis",
|
||||||
|
"chooseTemplate": "Escolha um modelo que corresponda ao seu tipo de projeto",
|
||||||
|
"createProject": "Criar projeto",
|
||||||
|
|
||||||
|
"templateSoftwareDev": "Desenvolvimento de Software",
|
||||||
|
"templateSoftwareDesc": "Sprints ágeis, rastreamento de bugs, lançamentos",
|
||||||
|
"templateMarketing": "Campanha de Marketing",
|
||||||
|
"templateMarketingDesc": "Planejamento de campanha, calendário de conteúdo",
|
||||||
|
"templateConstruction": "Projeto de Construção",
|
||||||
|
"templateConstructionDesc": "Fases, licenças, empreiteiros",
|
||||||
|
"templateStartup": "Lançamento de Startup",
|
||||||
|
"templateStartupDesc": "Desenvolvimento MVP, financiamento, crescimento",
|
||||||
|
|
||||||
|
"tasksStepTitle": "Adicione suas primeiras tarefas",
|
||||||
|
"tasksStepDescription": "Divida \"{{projectName}}\" em tarefas acionáveis para começar",
|
||||||
|
"taskPlaceholder": "Tarefa {{index}} - ex., O que precisa ser feito?",
|
||||||
|
"addAnotherTask": "Adicionar outra tarefa ({{current}}/{{max}})",
|
||||||
|
|
||||||
|
"surveyStepTitle": "Conte-nos sobre você",
|
||||||
|
"surveyStepLabel": "Ajude-nos a personalizar sua experiência no Worklenz respondendo algumas perguntas.",
|
||||||
|
|
||||||
|
"organizationType": "O que melhor descreve sua organização?",
|
||||||
|
"organizationTypeFreelancer": "Freelancer",
|
||||||
|
"organizationTypeStartup": "Startup",
|
||||||
|
"organizationTypeSmallMediumBusiness": "Pequena ou Média Empresa",
|
||||||
|
"organizationTypeAgency": "Agência",
|
||||||
|
"organizationTypeEnterprise": "Empresa",
|
||||||
|
"organizationTypeOther": "Outro",
|
||||||
|
|
||||||
|
"userRole": "Qual é o seu papel?",
|
||||||
|
"userRoleFounderCeo": "Fundador / CEO",
|
||||||
|
"userRoleProjectManager": "Gerente de Projeto",
|
||||||
|
"userRoleSoftwareDeveloper": "Desenvolvedor de Software",
|
||||||
|
"userRoleDesigner": "Designer",
|
||||||
|
"userRoleOperations": "Operações",
|
||||||
|
"userRoleOther": "Outro",
|
||||||
|
|
||||||
|
"mainUseCases": "Para que você usará principalmente o Worklenz?",
|
||||||
|
"mainUseCasesTaskManagement": "Gerenciamento de tarefas",
|
||||||
|
"mainUseCasesTeamCollaboration": "Colaboração em equipe",
|
||||||
|
"mainUseCasesResourcePlanning": "Planejamento de recursos",
|
||||||
|
"mainUseCasesClientCommunication": "Comunicação com clientes e relatórios",
|
||||||
|
"mainUseCasesTimeTracking": "Controle de tempo",
|
||||||
|
"mainUseCasesOther": "Outro",
|
||||||
|
|
||||||
|
"previousTools": "Que ferramenta(s) você usava antes do Worklenz?",
|
||||||
|
"previousToolsPlaceholder": "ex. Trello, Asana, Monday.com",
|
||||||
|
|
||||||
|
"howHeardAbout": "Como você soube do Worklenz?",
|
||||||
|
"howHeardAboutGoogleSearch": "Busca no Google",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "Um amigo ou colega",
|
||||||
|
"howHeardAboutBlogArticle": "Um blog ou artigo",
|
||||||
|
"howHeardAboutOther": "Outro",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "Conte-nos sobre você",
|
||||||
|
"aboutYouStepDescription": "Ajude-nos a personalizar sua experiência",
|
||||||
|
"yourNeedsStepTitle": "Quais são suas principais necessidades?",
|
||||||
|
"yourNeedsStepDescription": "Selecione todas que se aplicam para nos ajudar a configurar seu espaço de trabalho",
|
||||||
|
"selected": "selecionado",
|
||||||
|
"previousToolsLabel": "Que ferramentas você usou antes? (Opcional)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX, Gráficos, Criativo",
|
||||||
|
"developer": "Frontend, Backend, Full-stack",
|
||||||
|
"projectManager": "Planejamento, Coordenação",
|
||||||
|
"marketing": "Conteúdo, Mídias Sociais, Crescimento",
|
||||||
|
"sales": "Desenvolvimento de Negócios, Relacionamento com Clientes",
|
||||||
|
"operations": "Administração, RH, Finanças"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"pt": "Português",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "简体中文"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["Projeto Cliente", "Atualização Portfolio", "Marca Pessoal"],
|
||||||
|
"startup": ["Desenvolvimento MVP", "Lançamento Produto", "Pesquisa Mercado"],
|
||||||
|
"agency": ["Campanha Cliente", "Estratégia Marca", "Redesign Website"],
|
||||||
|
"enterprise": ["Migração Sistema", "Otimização Processos", "Treinamento Equipe"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "Organizar e rastrear tarefas",
|
||||||
|
"teamCollaboration": "Trabalhar juntos perfeitamente",
|
||||||
|
"resourcePlanning": "Gerenciar tempo e recursos",
|
||||||
|
"clientCommunication": "Manter-se conectado com clientes",
|
||||||
|
"timeTracking": "Monitorar horas do projeto",
|
||||||
|
"other": "Algo mais"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
"deleteConfirmationOk": "Sim",
|
"deleteConfirmationOk": "Sim",
|
||||||
"deleteConfirmationCancel": "Cancelar",
|
"deleteConfirmationCancel": "Cancelar",
|
||||||
|
|
||||||
|
"deleteTaskTitle": "Excluir Tarefa",
|
||||||
|
"deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.",
|
||||||
|
"deleteTaskConfirm": "Excluir",
|
||||||
|
"deleteTaskCancel": "Cancelar",
|
||||||
|
|
||||||
|
"deleteStatusTitle": "Excluir Status",
|
||||||
|
"deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.",
|
||||||
|
|
||||||
|
"deletePhaseTitle": "Excluir Fase",
|
||||||
|
"deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.",
|
||||||
|
|
||||||
"dueDate": "Data de vencimento",
|
"dueDate": "Data de vencimento",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
|
|
||||||
@@ -26,5 +37,17 @@
|
|||||||
"noDueDate": "Sem data de vencimento",
|
"noDueDate": "Sem data de vencimento",
|
||||||
"save": "Salvar",
|
"save": "Salvar",
|
||||||
"clear": "Limpar",
|
"clear": "Limpar",
|
||||||
"nextWeek": "Próxima semana"
|
"nextWeek": "Próxima semana",
|
||||||
|
"noSubtasks": "Sem subtarefas",
|
||||||
|
"showSubtasks": "Mostrar subtarefas",
|
||||||
|
"hideSubtasks": "Ocultar subtarefas",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "Erro ao carregar tarefas",
|
||||||
|
"noTasksFound": "Nenhuma tarefa encontrada",
|
||||||
|
"loadingFilters": "Carregando filtros...",
|
||||||
|
"failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas",
|
||||||
|
"failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases",
|
||||||
|
"pleaseTryAgain": "Por favor, tente novamente",
|
||||||
|
"taskNotCompleted": "Tarefa não está concluída",
|
||||||
|
"completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,5 +13,6 @@
|
|||||||
"deleteButtonTooltip": "Remover do projeto",
|
"deleteButtonTooltip": "Remover do projeto",
|
||||||
"memberCount": "Membro",
|
"memberCount": "Membro",
|
||||||
"membersCountPlural": "Membros",
|
"membersCountPlural": "Membros",
|
||||||
"emptyText": "Não há anexos no projeto."
|
"emptyText": "Não há anexos no projeto.",
|
||||||
|
"searchPlaceholder": "Pesquisar membros"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,9 @@
|
|||||||
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
"searchLabel": "Adicionar membros inserindo nome ou e-mail",
|
||||||
"searchPlaceholder": "Digite nome ou e-mail",
|
"searchPlaceholder": "Digite nome ou e-mail",
|
||||||
"inviteAsAMember": "Convidar como membro",
|
"inviteAsAMember": "Convidar como membro",
|
||||||
"inviteNewMemberByEmail": "Convidar novo membro por e-mail"
|
"inviteNewMemberByEmail": "Convidar novo membro por e-mail",
|
||||||
|
"members": "Membros",
|
||||||
|
"copyProjectLink": "Copiar link do projeto",
|
||||||
|
"inviteMember": "Convidar membro",
|
||||||
|
"alsoInviteToProject": "Convidar também para o projeto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "Título do Emprego",
|
"jobTitleLabel": "Título do Emprego",
|
||||||
"jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)",
|
"jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)",
|
||||||
"memberAccessLabel": "Nível de Acesso",
|
"memberAccessLabel": "Nível de Acesso",
|
||||||
"addToTeamButton": "Adicionar Membro à Equipe",
|
"addToTeamButton": "Enviar convite",
|
||||||
"updateButton": "Salvar Alterações",
|
"updateButton": "Salvar Alterações",
|
||||||
"resendInvitationButton": "Redirecionar Email de Convite",
|
"resendInvitationButton": "Redirecionar Email de Convite",
|
||||||
"invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!",
|
"invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "Atualizado",
|
"updatedText": "Atualizado",
|
||||||
"noResultFound": "Digite um endereço de email e pressione enter...",
|
"noResultFound": "Digite um endereço de email e pressione enter...",
|
||||||
"jobTitlesFetchError": "Falha ao buscar cargos",
|
"jobTitlesFetchError": "Falha ao buscar cargos",
|
||||||
"invitationResent": "Convite reenviado com sucesso!"
|
"invitationResent": "Convite reenviado com sucesso!",
|
||||||
|
"copyTeamLink": "Copiar link da equipe"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
{
|
{
|
||||||
"continue": "继续",
|
"continue": "继续",
|
||||||
"setupYourAccount": "设置您的Worklenz账户。",
|
"setupYourAccount": "设置您的 Worklenz 账户。",
|
||||||
"organizationStepTitle": "命名您的组织",
|
"organizationStepTitle": "命名您的组织",
|
||||||
"organizationStepLabel": "为您的Worklenz账户选择一个名称。",
|
"organizationStepWelcome": "欢迎使用 Worklenz!",
|
||||||
|
"organizationStepDescription": "让我们从设置您的组织开始。这将是您团队的主要工作空间。",
|
||||||
|
"organizationStepLabel": "组织名称",
|
||||||
|
"organizationStepPlaceholder": "例如:Acme 公司",
|
||||||
|
"organizationStepTooltip": "此名称将显示在您的工作区,并可在设置中更改。",
|
||||||
|
"organizationStepNeedIdeas": "需要灵感?",
|
||||||
|
"organizationStepUseDetected": "检测到使用:",
|
||||||
|
"organizationStepCharacters": "字符",
|
||||||
|
"organizationStepGoodLength": "长度合适",
|
||||||
|
"organizationStepTooShort": "太短",
|
||||||
|
"organizationStepNamingTips": "命名建议",
|
||||||
|
"organizationStepTip1": "保持简单且易记",
|
||||||
|
"organizationStepTip2": "体现您的行业或价值观",
|
||||||
|
"organizationStepTip3": "考虑未来发展",
|
||||||
|
"organizationStepTip4": "使其独特且有品牌感",
|
||||||
|
"organizationStepSuggestionsTitle": "名称建议",
|
||||||
|
"organizationStepCategory1": "科技公司",
|
||||||
|
"organizationStepCategory2": "创意机构",
|
||||||
|
"organizationStepCategory3": "咨询公司",
|
||||||
|
"organizationStepCategory4": "初创企业",
|
||||||
|
"organizationStepSuggestionsNote": "这些只是帮助您入门的示例。请选择能代表您组织的名称。",
|
||||||
|
"organizationStepPrivacyNote": "您的组织名称是私有的,仅团队成员可见。",
|
||||||
|
|
||||||
"projectStepTitle": "创建您的第一个项目",
|
"projectStepTitle": "创建您的第一个项目",
|
||||||
"projectStepLabel": "您现在正在做什么项目?",
|
"projectStepLabel": "您现在正在做什么项目?",
|
||||||
"projectStepPlaceholder": "例如:营销计划",
|
"projectStepPlaceholder": "例如:营销计划",
|
||||||
|
|
||||||
"tasksStepTitle": "创建您的第一个任务",
|
"tasksStepTitle": "创建您的第一个任务",
|
||||||
"tasksStepLabel": "输入您将在其中完成的几个任务",
|
"tasksStepLabel": "输入您将在其中完成的几个任务",
|
||||||
"tasksStepAddAnother": "添加另一个",
|
"tasksStepAddAnother": "添加另一个",
|
||||||
|
|
||||||
"emailPlaceholder": "电子邮件地址",
|
"emailPlaceholder": "电子邮件地址",
|
||||||
"invalidEmail": "请输入有效的电子邮件地址",
|
"invalidEmail": "请输入有效的电子邮件地址",
|
||||||
"or": "或",
|
"or": "或",
|
||||||
@@ -22,6 +46,165 @@
|
|||||||
"skipForNow": "暂时跳过",
|
"skipForNow": "暂时跳过",
|
||||||
"formTitle": "创建您的第一个任务。",
|
"formTitle": "创建您的第一个任务。",
|
||||||
"step3Title": "邀请您的团队一起工作",
|
"step3Title": "邀请您的团队一起工作",
|
||||||
"maxMembers": "(您最多可以邀请5名成员)",
|
"maxMembers": "(您最多可以邀请 5 名成员)",
|
||||||
"maxTasks": "(您最多可以创建5个任务)"
|
"maxTasks": "(您最多可以创建 5 个任务)",
|
||||||
|
|
||||||
|
"membersStepTitle": "邀请您的团队",
|
||||||
|
"membersStepDescription": "将团队成员添加到 \"{{organizationName}}\" 并开始协作",
|
||||||
|
"memberPlaceholder": "团队成员 {{index}} - 输入电子邮件地址",
|
||||||
|
"validEmailAddress": "有效的电子邮件地址",
|
||||||
|
"addAnotherTeamMember": "添加另一个团队成员 ({{current}}/{{max}})",
|
||||||
|
"canInviteLater": "您可以稍后邀请团队成员",
|
||||||
|
"skipStepDescription": "没有准备好电子邮件地址?没关系!您可以跳过此步骤,稍后从项目面板邀请团队成员。",
|
||||||
|
|
||||||
|
"orgCategoryTech": "科技公司",
|
||||||
|
"orgCategoryCreative": "创意机构",
|
||||||
|
"orgCategoryConsulting": "咨询公司",
|
||||||
|
"orgCategoryStartups": "初创企业",
|
||||||
|
"namingTip1": "保持简单且易记",
|
||||||
|
"namingTip2": "体现您的行业或价值观",
|
||||||
|
"namingTip3": "考虑未来发展",
|
||||||
|
"namingTip4": "使其独特且有品牌感",
|
||||||
|
|
||||||
|
"aboutYouTitle": "告诉我们关于您的信息",
|
||||||
|
"aboutYouDescription": "帮助我们个性化您的体验",
|
||||||
|
"orgTypeQuestion": "哪项最能描述您的组织?",
|
||||||
|
"userRoleQuestion": "您的角色是什么?",
|
||||||
|
|
||||||
|
"yourNeedsTitle": "您的主要需求是什么?",
|
||||||
|
"yourNeedsDescription": "请选择所有适用项,帮助我们设置您的工作区",
|
||||||
|
"yourNeedsQuestion": "您主要如何使用 Worklenz?",
|
||||||
|
"useCaseTaskOrg": "组织和跟踪任务",
|
||||||
|
"useCaseTeamCollab": "团队协作",
|
||||||
|
"useCaseResourceMgmt": "管理时间和资源",
|
||||||
|
"useCaseClientComm": "与客户保持联系",
|
||||||
|
"useCaseTimeTrack": "监控项目工时",
|
||||||
|
"useCaseOther": "其他",
|
||||||
|
"selectedText": "已选择",
|
||||||
|
"previousToolsQuestion": "您之前用过哪些工具?(可选)",
|
||||||
|
"previousToolsPlaceholder": "例如:Asana、Trello、Jira、Monday.com 等",
|
||||||
|
|
||||||
|
"discoveryTitle": "最后一个问题……",
|
||||||
|
"discoveryDescription": "帮助我们了解您是如何发现 Worklenz 的",
|
||||||
|
"discoveryQuestion": "您是如何听说我们的?",
|
||||||
|
"allSetTitle": "一切就绪!",
|
||||||
|
"allSetDescription": "让我们创建您的第一个项目并开始使用 Worklenz 吧",
|
||||||
|
"aboutYouStepName": "关于您",
|
||||||
|
"yourNeedsStepName": "您的需求",
|
||||||
|
"discoveryStepName": "发现",
|
||||||
|
"stepProgress": "第 {step} 步,共 3 步:{title}",
|
||||||
|
|
||||||
|
"projectStepHeader": "让我们创建您的第一个项目",
|
||||||
|
"projectStepSubheader": "从头开始或使用模板更快上手",
|
||||||
|
"startFromScratch": "从头开始",
|
||||||
|
"templateSelected": "已选择模板如下",
|
||||||
|
"quickSuggestions": "快速建议:",
|
||||||
|
"orText": "或",
|
||||||
|
"startWithTemplate": "从模板开始",
|
||||||
|
"clearToSelectTemplate": "请先清空上方项目名称以选择模板",
|
||||||
|
"templateHeadStart": "使用预设项目结构快速开始",
|
||||||
|
"browseAllTemplates": "浏览所有模板",
|
||||||
|
"templatesAvailable": "15+ 行业专用模板可用",
|
||||||
|
"chooseTemplate": "选择与您的项目类型匹配的模板",
|
||||||
|
"createProject": "创建项目",
|
||||||
|
|
||||||
|
"templateSoftwareDev": "软件开发",
|
||||||
|
"templateSoftwareDesc": "敏捷冲刺、缺陷跟踪、版本发布",
|
||||||
|
"templateMarketing": "市场营销活动",
|
||||||
|
"templateMarketingDesc": "活动策划、内容日历",
|
||||||
|
"templateConstruction": "建设项目",
|
||||||
|
"templateConstructionDesc": "阶段、许可、承包商",
|
||||||
|
"templateStartup": "初创启动",
|
||||||
|
"templateStartupDesc": "MVP 开发、融资、增长",
|
||||||
|
|
||||||
|
"tasksStepTitle": "添加您的第一个任务",
|
||||||
|
"tasksStepDescription": "将 \"{{projectName}}\" 拆分为可执行任务以开始",
|
||||||
|
"taskPlaceholder": "任务 {{index}} - 例如:需要做什么?",
|
||||||
|
"addAnotherTask": "添加另一个任务 ({{current}}/{{max}})",
|
||||||
|
|
||||||
|
"surveyStepTitle": "告诉我们关于您的信息",
|
||||||
|
"surveyStepLabel": "通过回答几个问题帮助我们个性化您的 Worklenz 体验。",
|
||||||
|
|
||||||
|
"organizationType": "哪项最能描述您的组织?",
|
||||||
|
"organizationTypeFreelancer": "自由职业者",
|
||||||
|
"organizationTypeStartup": "初创公司",
|
||||||
|
"organizationTypeSmallMediumBusiness": "中小企业",
|
||||||
|
"organizationTypeAgency": "代理机构",
|
||||||
|
"organizationTypeEnterprise": "企业",
|
||||||
|
"organizationTypeOther": "其他",
|
||||||
|
|
||||||
|
"userRole": "您的角色是什么?",
|
||||||
|
"userRoleFounderCeo": "创始人 / CEO",
|
||||||
|
"userRoleProjectManager": "项目经理",
|
||||||
|
"userRoleSoftwareDeveloper": "软件开发者",
|
||||||
|
"userRoleDesigner": "设计师",
|
||||||
|
"userRoleOperations": "运营",
|
||||||
|
"userRoleOther": "其他",
|
||||||
|
|
||||||
|
"mainUseCases": "您主要将 Worklenz 用于什么?",
|
||||||
|
"mainUseCasesTaskManagement": "任务管理",
|
||||||
|
"mainUseCasesTeamCollaboration": "团队协作",
|
||||||
|
"mainUseCasesResourcePlanning": "资源规划",
|
||||||
|
"mainUseCasesClientCommunication": "客户沟通与报告",
|
||||||
|
"mainUseCasesTimeTracking": "时间跟踪",
|
||||||
|
"mainUseCasesOther": "其他",
|
||||||
|
|
||||||
|
"previousTools": "在使用 Worklenz 之前您用过哪些工具?",
|
||||||
|
"previousToolsPlaceholder": "例如:Trello、Asana、Monday.com",
|
||||||
|
|
||||||
|
"howHeardAbout": "您是如何了解 Worklenz 的?",
|
||||||
|
"howHeardAboutGoogleSearch": "Google 搜索",
|
||||||
|
"howHeardAboutTwitter": "Twitter",
|
||||||
|
"howHeardAboutLinkedin": "LinkedIn",
|
||||||
|
"howHeardAboutFriendColleague": "朋友或同事",
|
||||||
|
"howHeardAboutBlogArticle": "博客或文章",
|
||||||
|
"howHeardAboutOther": "其他",
|
||||||
|
|
||||||
|
"aboutYouStepTitle": "告诉我们关于您的信息",
|
||||||
|
"aboutYouStepDescription": "帮助我们个性化您的体验",
|
||||||
|
"yourNeedsStepTitle": "您的主要需求是什么?",
|
||||||
|
"yourNeedsStepDescription": "选择所有适用的选项,帮助我们设置您的工作空间",
|
||||||
|
"selected": "已选择",
|
||||||
|
"previousToolsLabel": "您之前使用过哪些工具?(可选)",
|
||||||
|
|
||||||
|
"roleSuggestions": {
|
||||||
|
"designer": "UI/UX、图形、创意",
|
||||||
|
"developer": "前端、后端、全栈",
|
||||||
|
"projectManager": "规划、协调",
|
||||||
|
"marketing": "内容、社交媒体、增长",
|
||||||
|
"sales": "业务发展、客户关系",
|
||||||
|
"operations": "行政、人力资源、财务"
|
||||||
|
},
|
||||||
|
|
||||||
|
"languages": {
|
||||||
|
"en": "English",
|
||||||
|
"es": "Español",
|
||||||
|
"pt": "Português",
|
||||||
|
"de": "Deutsch",
|
||||||
|
"alb": "Shqip",
|
||||||
|
"zh": "简体中文"
|
||||||
|
},
|
||||||
|
|
||||||
|
"orgSuggestions": {
|
||||||
|
"tech": ["TechCorp", "DevStudio", "CodeCraft", "PixelForge"],
|
||||||
|
"creative": ["Creative Hub", "Design Studio", "Brand Works", "Visual Arts"],
|
||||||
|
"consulting": ["Strategy Group", "Business Solutions", "Expert Advisors", "Growth Partners"],
|
||||||
|
"startup": ["Innovation Labs", "Future Works", "Venture Co", "Next Gen"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"projectSuggestions": {
|
||||||
|
"freelancer": ["客户项目", "作品集更新", "个人品牌"],
|
||||||
|
"startup": ["MVP开发", "产品发布", "市场调研"],
|
||||||
|
"agency": ["客户活动", "品牌策略", "网站重设计"],
|
||||||
|
"enterprise": ["系统迁移", "流程优化", "团队培训"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"useCaseDescriptions": {
|
||||||
|
"taskManagement": "组织和跟踪任务",
|
||||||
|
"teamCollaboration": "无缝协作",
|
||||||
|
"resourcePlanning": "管理时间和资源",
|
||||||
|
"clientCommunication": "与客户保持联系",
|
||||||
|
"timeTracking": "监控项目时间",
|
||||||
|
"other": "其他"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,5 +15,32 @@
|
|||||||
"assignToMe": "分配给我",
|
"assignToMe": "分配给我",
|
||||||
"archive": "归档",
|
"archive": "归档",
|
||||||
"newTaskNamePlaceholder": "写一个任务名称",
|
"newTaskNamePlaceholder": "写一个任务名称",
|
||||||
"newSubtaskNamePlaceholder": "写一个子任务名称"
|
"newSubtaskNamePlaceholder": "写一个子任务名称",
|
||||||
|
"deleteTaskTitle": "删除任务",
|
||||||
|
"deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。",
|
||||||
|
"deleteTaskConfirm": "删除",
|
||||||
|
"deleteTaskCancel": "取消",
|
||||||
|
"deleteStatusTitle": "删除状态",
|
||||||
|
"deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。",
|
||||||
|
"deletePhaseTitle": "删除阶段",
|
||||||
|
"deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。",
|
||||||
|
"untitledSection": "未命名部分",
|
||||||
|
"unmapped": "未映射",
|
||||||
|
"clickToChangeDate": "点击更改日期",
|
||||||
|
"noDueDate": "无截止日期",
|
||||||
|
"save": "保存",
|
||||||
|
"clear": "清除",
|
||||||
|
"nextWeek": "下周",
|
||||||
|
"noSubtasks": "无子任务",
|
||||||
|
"showSubtasks": "显示子任务",
|
||||||
|
"hideSubtasks": "隐藏子任务",
|
||||||
|
|
||||||
|
"errorLoadingTasks": "加载任务时出错",
|
||||||
|
"noTasksFound": "未找到任务",
|
||||||
|
"loadingFilters": "正在加载过滤器...",
|
||||||
|
"failedToUpdateColumnOrder": "更新列顺序失败",
|
||||||
|
"failedToUpdatePhaseOrder": "更新阶段顺序失败",
|
||||||
|
"pleaseTryAgain": "请重试",
|
||||||
|
"taskNotCompleted": "任务未完成",
|
||||||
|
"completeTaskDependencies": "请先完成任务依赖项,然后再继续"
|
||||||
}
|
}
|
||||||
@@ -3,5 +3,9 @@
|
|||||||
"searchLabel": "通过添加名称或电子邮件添加成员",
|
"searchLabel": "通过添加名称或电子邮件添加成员",
|
||||||
"searchPlaceholder": "输入名称或电子邮件",
|
"searchPlaceholder": "输入名称或电子邮件",
|
||||||
"inviteAsAMember": "邀请为成员",
|
"inviteAsAMember": "邀请为成员",
|
||||||
"inviteNewMemberByEmail": "通过电子邮件邀请新成员"
|
"inviteNewMemberByEmail": "通过电子邮件邀请新成员",
|
||||||
|
"members": "成员",
|
||||||
|
"copyProjectLink": "复制项目链接",
|
||||||
|
"inviteMember": "邀请成员",
|
||||||
|
"alsoInviteToProject": "也邀请到项目"
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"jobTitleLabel": "职位",
|
"jobTitleLabel": "职位",
|
||||||
"jobTitlePlaceholder": "选择或搜索职位(可选)",
|
"jobTitlePlaceholder": "选择或搜索职位(可选)",
|
||||||
"memberAccessLabel": "访问级别",
|
"memberAccessLabel": "访问级别",
|
||||||
"addToTeamButton": "将成员添加到团队",
|
"addToTeamButton": "发送邀请",
|
||||||
"updateButton": "保存更改",
|
"updateButton": "保存更改",
|
||||||
"resendInvitationButton": "重新发送邀请邮件",
|
"resendInvitationButton": "重新发送邀请邮件",
|
||||||
"invitationSentSuccessMessage": "团队邀请已成功发送!",
|
"invitationSentSuccessMessage": "团队邀请已成功发送!",
|
||||||
@@ -43,5 +43,6 @@
|
|||||||
"updatedText": "已更新",
|
"updatedText": "已更新",
|
||||||
"noResultFound": "输入电子邮件地址并按回车键...",
|
"noResultFound": "输入电子邮件地址并按回车键...",
|
||||||
"jobTitlesFetchError": "获取职位失败",
|
"jobTitlesFetchError": "获取职位失败",
|
||||||
"invitationResent": "邀请重新发送成功!"
|
"invitationResent": "邀请重新发送成功!",
|
||||||
|
"copyTeamLink": "复制团队链接"
|
||||||
}
|
}
|
||||||
55
worklenz-frontend/sonar-project.properties
Normal file
55
worklenz-frontend/sonar-project.properties
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# SonarQube Configuration for Worklenz Frontend
|
||||||
|
sonar.projectKey=worklenz-frontend
|
||||||
|
sonar.projectName=Worklenz Frontend
|
||||||
|
sonar.projectVersion=1.0.0
|
||||||
|
|
||||||
|
# Source code configuration
|
||||||
|
sonar.sources=src
|
||||||
|
sonar.tests=src
|
||||||
|
sonar.test.inclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx
|
||||||
|
|
||||||
|
# Language-specific configurations
|
||||||
|
sonar.typescript.node=node
|
||||||
|
sonar.typescript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
|
||||||
|
# Exclusions
|
||||||
|
sonar.exclusions=**/node_modules/**,\
|
||||||
|
**/build/**,\
|
||||||
|
**/dist/**,\
|
||||||
|
**/public/**,\
|
||||||
|
**/*.d.ts,\
|
||||||
|
src/react-app-env.d.ts,\
|
||||||
|
src/vite-env.d.ts,\
|
||||||
|
**/*.config.js,\
|
||||||
|
**/*.config.ts,\
|
||||||
|
**/*.config.mts,\
|
||||||
|
scripts/**
|
||||||
|
|
||||||
|
# Test exclusions from coverage
|
||||||
|
sonar.coverage.exclusions=**/*.test.ts,\
|
||||||
|
**/*.test.tsx,\
|
||||||
|
**/*.spec.ts,\
|
||||||
|
**/*.spec.tsx,\
|
||||||
|
**/*.config.*,\
|
||||||
|
src/index.tsx,\
|
||||||
|
src/reportWebVitals.ts,\
|
||||||
|
src/serviceWorkerRegistration.ts,\
|
||||||
|
src/setupTests.ts
|
||||||
|
|
||||||
|
# Code quality rules
|
||||||
|
sonar.qualitygate.wait=true
|
||||||
|
|
||||||
|
# File encoding
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# JavaScript/TypeScript specific settings
|
||||||
|
sonar.javascript.environments=browser,node,jest
|
||||||
|
sonar.typescript.tsconfigPath=tsconfig.json
|
||||||
|
|
||||||
|
# ESLint configuration (if available)
|
||||||
|
# sonar.eslint.reportPaths=eslint-report.json
|
||||||
|
|
||||||
|
# Additional settings for React projects
|
||||||
|
sonar.javascript.file.suffixes=.js,.jsx
|
||||||
|
sonar.typescript.file.suffixes=.ts,.tsx
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { API_BASE_URL } from '@/shared/constants';
|
||||||
|
import { toQueryString } from '@/utils/toQueryString';
|
||||||
|
import apiClient from '../api-client';
|
||||||
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
import { IAllocationViewModel } from '@/types/reporting/reporting-allocation.types';
|
||||||
|
import {
|
||||||
|
IProjectLogsBreakdown,
|
||||||
|
IRPTTimeMember,
|
||||||
|
IRPTTimeProject,
|
||||||
|
ITimeLogBreakdownReq,
|
||||||
|
} from '@/types/reporting/reporting.types';
|
||||||
|
|
||||||
|
const rootUrl = `${API_BASE_URL}/reporting`;
|
||||||
|
|
||||||
|
// Helper function to get user's timezone
|
||||||
|
const getUserTimezone = () => {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reportingTimesheetApiService = {
|
||||||
|
getTimeSheetData: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IAllocationViewModel>> => {
|
||||||
|
const q = toQueryString({ archived });
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/allocation/${q}`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllocationProjects: async (body = {}) => {
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/allocation/allocation-projects`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectTimeSheets: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||||
|
const q = toQueryString({ archived });
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/time-reports/projects/${q}`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMemberTimeSheets: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeMember[]>> => {
|
||||||
|
const q = toQueryString({ archived });
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/time-reports/members/${q}`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectTimeLogs: async (
|
||||||
|
body: ITimeLogBreakdownReq
|
||||||
|
): Promise<IServerResponse<IProjectLogsBreakdown[]>> => {
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/project-timelogs`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjectEstimatedVsActual: async (
|
||||||
|
body = {},
|
||||||
|
archived = false
|
||||||
|
): Promise<IServerResponse<IRPTTimeProject[]>> => {
|
||||||
|
const q = toQueryString({ archived });
|
||||||
|
const bodyWithTimezone = {
|
||||||
|
...body,
|
||||||
|
timezone: getUserTimezone()
|
||||||
|
};
|
||||||
|
const response = await apiClient.post(`${rootUrl}/time-reports/estimated-vs-actual${q}`, bodyWithTimezone);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
22
worklenz-frontend/src/api/survey/survey.api.service.ts
Normal file
22
worklenz-frontend/src/api/survey/survey.api.service.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { IServerResponse } from '@/types/common.types';
|
||||||
|
import { ISurvey, ISurveySubmissionRequest, ISurveyResponse } from '@/types/account-setup/survey.types';
|
||||||
|
import apiClient from '../api-client';
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api/v1';
|
||||||
|
|
||||||
|
export const surveyApiService = {
|
||||||
|
async getAccountSetupSurvey(): Promise<IServerResponse<ISurvey>> {
|
||||||
|
const response = await apiClient.get<IServerResponse<ISurvey>>(`${API_BASE_URL}/surveys/account-setup`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitSurveyResponse(data: ISurveySubmissionRequest): Promise<IServerResponse<{ response_id: string }>> {
|
||||||
|
const response = await apiClient.post<IServerResponse<{ response_id: string }>>(`${API_BASE_URL}/surveys/responses`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserSurveyResponse(surveyId: string): Promise<IServerResponse<ISurveyResponse>> {
|
||||||
|
const response = await apiClient.get<IServerResponse<ISurveyResponse>>(`${API_BASE_URL}/surveys/responses/${surveyId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ const ProjectTemplateEditView = lazy(
|
|||||||
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
|
const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired'));
|
||||||
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
|
const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view'));
|
||||||
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
|
const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized'));
|
||||||
|
const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage'));
|
||||||
|
|
||||||
// Define AdminGuard component with defensive programming
|
// Define AdminGuard component with defensive programming
|
||||||
const AdminGuard = ({ children }: { children: React.ReactNode }) => {
|
const AdminGuard = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -106,6 +107,14 @@ const mainRoutes: RouteObject[] = [
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'gantt-demo',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<SuspenseFallback />}>
|
||||||
|
<GanttDemoPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
...settingsRoutes,
|
...settingsRoutes,
|
||||||
...adminCenterRoutes,
|
...adminCenterRoutes,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useSelector } from 'react-redux';
|
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 { RootState } from '@/app/store';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||||
@@ -11,8 +11,10 @@ import { useAuthService } from '@/hooks/useAuth';
|
|||||||
import { Avatar, Button, Checkbox } from '@/components';
|
import { Avatar, Button, Checkbox } from '@/components';
|
||||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
import { setIsFromAssigner, toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
|
import { useAuthStatus } from '@/hooks/useAuthStatus';
|
||||||
|
|
||||||
interface AssigneeSelectorProps {
|
interface AssigneeSelectorProps {
|
||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
@@ -42,6 +44,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const { isAdmin } = useAuthStatus();
|
||||||
|
const isProjectManager = useIsProjectManager();
|
||||||
|
|
||||||
const filteredMembers = useMemo(() => {
|
const filteredMembers = useMemo(() => {
|
||||||
return teamMembers?.data?.filter(member =>
|
return teamMembers?.data?.filter(member =>
|
||||||
@@ -64,7 +68,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||||
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,8 +79,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
if (buttonRef.current) {
|
if (buttonRef.current) {
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
const isVisible = rect.top >= 0 && rect.left >= 0 &&
|
const isVisible = rect.top >= 0 && rect.left >= 0 &&
|
||||||
rect.bottom <= window.innerHeight &&
|
rect.bottom <= window.innerHeight &&
|
||||||
rect.right <= window.innerWidth;
|
rect.right <= window.innerWidth;
|
||||||
|
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
updateDropdownPosition();
|
updateDropdownPosition();
|
||||||
@@ -206,6 +210,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
|
|
||||||
const handleInviteProjectMemberDrawer = () => {
|
const handleInviteProjectMemberDrawer = () => {
|
||||||
setIsOpen(false); // Close the assignee dropdown first
|
setIsOpen(false); // Close the assignee dropdown first
|
||||||
|
dispatch(setIsFromAssigner(true));
|
||||||
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,12 +306,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{pendingChanges.has(member.id || '') && (
|
{pendingChanges.has(member.id || '') && (
|
||||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
<div className={`absolute inset-0 flex items-center justify-center ${isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
}`}>
|
||||||
}`}>
|
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
}`} />
|
||||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
|
||||||
}`} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -339,22 +342,26 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
|
||||||
<button
|
{(isAdmin || isProjectManager) && (
|
||||||
className={`
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
<button
|
||||||
transition-colors
|
className={`
|
||||||
${isDarkMode
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
? 'text-blue-400 hover:bg-gray-700'
|
transition-colors
|
||||||
: 'text-blue-600 hover:bg-blue-50'
|
${isDarkMode
|
||||||
}
|
? 'text-blue-400 hover:bg-gray-700'
|
||||||
`}
|
: 'text-blue-600 hover:bg-blue-50'
|
||||||
onClick={handleInviteProjectMemberDrawer}
|
}
|
||||||
>
|
`}
|
||||||
<UserAddOutlined />
|
onClick={handleInviteProjectMemberDrawer}
|
||||||
Invite member
|
>
|
||||||
</button>
|
<UserAddOutlined />
|
||||||
</div>
|
Invite member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 logo from '@/assets/images/worklenz-light-mode.png';
|
||||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from '@/shared/antd-imports';
|
||||||
import { Label } from '@/types/task-management.types';
|
import { Label } from '@/types/task-management.types';
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from '@/shared/antd-imports';
|
||||||
import { NumbersColorMap } from '@/shared/constants';
|
import { NumbersColorMap } from '@/shared/constants';
|
||||||
|
|
||||||
interface CustomNumberLabelProps {
|
interface CustomNumberLabelProps {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@/shared/antd-imports';
|
||||||
import { Input } from 'antd';
|
import { Input } from '@/shared/antd-imports';
|
||||||
|
|
||||||
type CustomSearchbarProps = {
|
type CustomSearchbarProps = {
|
||||||
placeholderText: string;
|
placeholderText: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Flex, Tooltip, Typography } from 'antd';
|
import { Flex, Tooltip, Typography } from '@/shared/antd-imports';
|
||||||
import { colors } from '../styles/colors';
|
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
|
// this custom table title used when the typography font weigh 500 needed
|
||||||
const CustomTableTitle = ({ title, tooltip }: { title: string; tooltip?: string | null }) => {
|
const CustomTableTitle = ({ title, tooltip }: { title: string; tooltip?: string | null }) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Empty, Typography } from 'antd';
|
import { Empty, Typography } from '@/shared/antd-imports';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button, Result } from 'antd';
|
import { Button, Result } from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useSelector } from 'react-redux';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
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';
|
import CacheCleanup from '@/utils/cache-cleanup';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localStorageFunctions';
|
import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localStorageFunctions';
|
||||||
import { Button, ConfigProvider, Tooltip } from 'antd';
|
import { Button, ConfigProvider, Tooltip } from '@/shared/antd-imports';
|
||||||
import { PushpinFilled, PushpinOutlined } from '@ant-design/icons';
|
import { PushpinFilled, PushpinOutlined } from '@/shared/antd-imports';
|
||||||
import { colors } from '../styles/colors';
|
import { colors } from '../styles/colors';
|
||||||
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
|
import { navRoutes, NavRoutesType } from '../lib/navbar/navRoutes';
|
||||||
|
|
||||||
// Props type for the component
|
// Props type for the component
|
||||||
type PinRouteToNavbarButtonProps = {
|
type PinRouteToNavbarButtonProps = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FloatButton, Space, Tooltip } from 'antd';
|
import { FloatButton, Space, Tooltip } from '@/shared/antd-imports';
|
||||||
import { FormatPainterOutlined } from '@ant-design/icons';
|
import { FormatPainterOutlined } from '@/shared/antd-imports';
|
||||||
// import LanguageSelector from '../features/i18n/language-selector';
|
// import LanguageSelector from '../features/i18n/language-selector';
|
||||||
// import ThemeSelector from '../features/theme/ThemeSelector';
|
// import ThemeSelector from '../features/theme/ThemeSelector';
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
@media (max-width: 1000px) {
|
|
||||||
.step-content,
|
|
||||||
.step-form,
|
|
||||||
.create-first-task-form,
|
|
||||||
.setup-action-buttons,
|
|
||||||
.invite-members-form {
|
|
||||||
width: 400px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.step-content,
|
|
||||||
.step-form,
|
|
||||||
.create-first-task-form,
|
|
||||||
.setup-action-buttons,
|
|
||||||
.invite-members-form {
|
|
||||||
width: 200px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, Input, Button, List, Alert, message, InputRef } from 'antd';
|
import { Form, Input, Button, Typography, Card, Avatar, Tag, Alert, Space, Dropdown, MenuProps } from '@/shared/antd-imports';
|
||||||
import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@ant-design/icons';
|
import { CloseCircleOutlined, MailOutlined, PlusOutlined, UserOutlined, CheckCircleOutlined, ExclamationCircleOutlined, GlobalOutlined } from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Typography } from 'antd';
|
import { setTeamMembers } from '@/features/account-setup/account-setup.slice';
|
||||||
import { setTeamMembers, setTasks } from '@/features/account-setup/account-setup.slice';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { validateEmail } from '@/utils/validateEmail';
|
import { validateEmail } from '@/utils/validateEmail';
|
||||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||||
import { Rule } from 'antd/es/form';
|
import { setLanguage } from '@/features/i18n/localesSlice';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface Email {
|
interface Email {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -20,162 +19,232 @@ interface Email {
|
|||||||
interface MembersStepProps {
|
interface MembersStepProps {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
styles: any;
|
styles: any;
|
||||||
|
token?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MembersStep: React.FC<MembersStepProps> = ({ isDarkMode, styles }) => {
|
const getEmailSuggestions = (orgName?: string) => {
|
||||||
const { t } = useTranslation('account-setup');
|
if (!orgName) return [];
|
||||||
|
const cleanOrgName = orgName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
return [`info@${cleanOrgName}.com`, `team@${cleanOrgName}.com`, `hello@${cleanOrgName}.com`, `contact@${cleanOrgName}.com`];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleSuggestions = (t: any) => [
|
||||||
|
{ role: 'Designer', icon: '🎨', description: t('roleSuggestions.designer') },
|
||||||
|
{ role: 'Developer', icon: '💻', description: t('roleSuggestions.developer') },
|
||||||
|
{ role: 'Project Manager', icon: '📊', description: t('roleSuggestions.projectManager') },
|
||||||
|
{ role: 'Marketing', icon: '📢', description: t('roleSuggestions.marketing') },
|
||||||
|
{ role: 'Sales', icon: '💼', description: t('roleSuggestions.sales') },
|
||||||
|
{ role: 'Operations', icon: '⚙️', description: t('roleSuggestions.operations') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MembersStep: React.FC<MembersStepProps> = ({ isDarkMode, styles, token }) => {
|
||||||
|
const { t, i18n } = useTranslation('account-setup');
|
||||||
const { teamMembers, organizationName } = useSelector(
|
const { teamMembers, organizationName } = useSelector(
|
||||||
(state: RootState) => state.accountSetupReducer
|
(state: RootState) => state.accountSetupReducer
|
||||||
);
|
);
|
||||||
const inputRefs = useRef<(InputRef | null)[]>([]);
|
const { language } = useSelector((state: RootState) => state.localesReducer);
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [form] = Form.useForm();
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [validatedEmails, setValidatedEmails] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const emailSuggestions = getEmailSuggestions(organizationName);
|
||||||
|
|
||||||
const addEmail = () => {
|
const addEmail = () => {
|
||||||
if (teamMembers.length == 5) return;
|
if (teamMembers.length >= 5) return;
|
||||||
|
|
||||||
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
|
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
|
||||||
dispatch(setTeamMembers([...teamMembers, { id: newId, value: '' }]));
|
dispatch(setTeamMembers([...teamMembers, { id: newId, value: '' }]));
|
||||||
setTimeout(() => {
|
setTimeout(() => inputRefs.current[teamMembers.length]?.focus(), 100);
|
||||||
inputRefs.current[newId]?.focus();
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeEmail = (id: number) => {
|
const removeEmail = (id: number) => {
|
||||||
if (teamMembers.length > 1) {
|
if (teamMembers.length > 1) dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id)));
|
||||||
dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id)));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateEmail = (id: number, value: string) => {
|
const updateEmail = (id: number, value: string) => {
|
||||||
const sanitizedValue = sanitizeInput(value);
|
const sanitizedValue = sanitizeInput(value);
|
||||||
dispatch(
|
dispatch(setTeamMembers(teamMembers.map(teamMember => teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember)));
|
||||||
setTeamMembers(
|
|
||||||
teamMembers.map(teamMember =>
|
|
||||||
teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
if (e.key === 'Enter') {
|
||||||
if (!input.value.trim()) return;
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
e.preventDefault();
|
if (input.value.trim() && validateEmail(input.value.trim())) {
|
||||||
addEmail();
|
e.preventDefault();
|
||||||
|
if (index === teamMembers.length - 1 && teamMembers.length < 5) addEmail();
|
||||||
|
else if (index < teamMembers.length - 1) inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to set ref that doesn't return anything (void)
|
const handleSuggestionClick = (suggestion: string) => {
|
||||||
const setInputRef = (index: number) => (el: InputRef | null) => {
|
const emptyEmailIndex = teamMembers.findIndex(member => !member.value.trim());
|
||||||
inputRefs.current[index] = el;
|
if (emptyEmailIndex !== -1) {
|
||||||
|
updateEmail(teamMembers[emptyEmailIndex].id, suggestion);
|
||||||
|
} else if (teamMembers.length < 5) {
|
||||||
|
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
|
||||||
|
dispatch(setTeamMembers([...teamMembers, { id: newId, value: suggestion }]));
|
||||||
|
}
|
||||||
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => inputRefs.current[0]?.focus(), 200);
|
||||||
inputRefs.current[teamMembers.length - 1]?.focus();
|
|
||||||
// Set initial form values
|
|
||||||
const initialValues: Record<string, string> = {};
|
|
||||||
teamMembers.forEach(teamMember => {
|
|
||||||
initialValues[`email-${teamMember.id}`] = teamMember.value;
|
|
||||||
});
|
|
||||||
form.setFieldsValue(initialValues);
|
|
||||||
}, 200);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formRules = {
|
const getEmailStatus = (email: string, memberId: number) => {
|
||||||
email: [
|
if (!email.trim()) return 'empty';
|
||||||
{
|
if (!validatedEmails.has(memberId)) return 'empty';
|
||||||
validator: async (_: any, value: string) => {
|
return validateEmail(email) ? 'valid' : 'invalid';
|
||||||
if (!value) return;
|
|
||||||
if (!validateEmail(value)) {
|
|
||||||
throw new Error(t('invalidEmail'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlur = (memberId: number, email: string) => {
|
||||||
|
setFocusedIndex(null);
|
||||||
|
if (email.trim()) setValidatedEmails(prev => new Set(prev).add(memberId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ key: 'en', label: t('languages.en'), flag: '🇺🇸' },
|
||||||
|
{ key: 'es', label: t('languages.es'), flag: '🇪🇸' },
|
||||||
|
{ key: 'pt', label: t('languages.pt'), flag: '🇵🇹' },
|
||||||
|
{ key: 'de', label: t('languages.de'), flag: '🇩🇪' },
|
||||||
|
{ key: 'alb', label: t('languages.alb'), flag: '🇦🇱' },
|
||||||
|
{ key: 'zh', label: t('languages.zh'), flag: '🇨🇳' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLanguageChange = (languageKey: string) => {
|
||||||
|
dispatch(setLanguage(languageKey));
|
||||||
|
i18n.changeLanguage(languageKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLanguage = languages.find(lang => lang.key === language) || languages[0];
|
||||||
|
const languageMenuItems: MenuProps['items'] = languages.map(lang => ({ key: lang.key, label: <div className="flex items-center space-x-2"><span>{lang.flag}</span><span>{lang.label}</span></div>, onClick: () => handleLanguageChange(lang.key) }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div className="w-full members-step">
|
||||||
form={form}
|
{/* Header */}
|
||||||
className="invite-members-form"
|
<div className="text-center mb-8">
|
||||||
style={{
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
minHeight: '300px',
|
{t('membersStepTitle')}
|
||||||
width: '600px',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
marginBottom: '3rem',
|
|
||||||
marginTop: '3rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item>
|
|
||||||
<Title level={2} style={{ marginBottom: '1rem' }}>
|
|
||||||
{t('step3Title')} "<mark>{organizationName}</mark>".
|
|
||||||
</Title>
|
</Title>
|
||||||
</Form.Item>
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
<Form.Item
|
{t('membersStepDescription', { organizationName })}
|
||||||
layout="vertical"
|
</Paragraph>
|
||||||
rules={[{ required: true }]}
|
</div>
|
||||||
label={
|
|
||||||
<span className="font-medium">
|
{/* Team Members List */}
|
||||||
{t('step3InputLabel')} <MailOutlined /> {t('maxMembers')}
|
<div className="mb-6">
|
||||||
</span>
|
<div className="space-y-3">
|
||||||
}
|
{teamMembers.map((teamMember, index) => {
|
||||||
>
|
const emailStatus = getEmailStatus(teamMember.value, teamMember.id);
|
||||||
<List
|
return (
|
||||||
dataSource={teamMembers}
|
<div
|
||||||
bordered={false}
|
key={teamMember.id}
|
||||||
itemLayout="vertical"
|
className={`flex items-center space-x-3 p-3 rounded-lg border transition-all duration-200 ${
|
||||||
renderItem={(teamMember, index) => (
|
focusedIndex === index ? 'border-2' : ''
|
||||||
<List.Item key={teamMember.id}>
|
}`}
|
||||||
<div className="invite-members-form" style={{ display: 'flex', width: '600px' }}>
|
style={{
|
||||||
<Form.Item
|
borderColor: focusedIndex === index ? token?.colorPrimary :
|
||||||
rules={formRules.email as Rule[]}
|
emailStatus === 'invalid' ? token?.colorError : token?.colorBorder,
|
||||||
className="w-full"
|
backgroundColor: token?.colorBgContainer
|
||||||
validateTrigger={['onChange', 'onBlur']}
|
}}
|
||||||
name={`email-${teamMember.id}`}
|
>
|
||||||
>
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
style={{
|
||||||
|
backgroundColor: emailStatus === 'valid' ? token?.colorSuccess :
|
||||||
|
emailStatus === 'invalid' ? token?.colorError : token?.colorBorderSecondary,
|
||||||
|
color: '#fff'
|
||||||
|
}}
|
||||||
|
icon={
|
||||||
|
emailStatus === 'valid' ? <CheckCircleOutlined /> :
|
||||||
|
emailStatus === 'invalid' ? <ExclamationCircleOutlined /> :
|
||||||
|
<UserOutlined />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('emailPlaceholder')}
|
placeholder={t('memberPlaceholder', { index: index + 1 })}
|
||||||
value={teamMember.value}
|
value={teamMember.value}
|
||||||
onChange={e => updateEmail(teamMember.id, e.target.value)}
|
onChange={e => updateEmail(teamMember.id, e.target.value)}
|
||||||
onPressEnter={handleKeyPress}
|
onKeyPress={e => handleKeyPress(e, index)}
|
||||||
ref={setInputRef(index)}
|
onFocus={() => setFocusedIndex(index)}
|
||||||
status={teamMember.value && !validateEmail(teamMember.value) ? 'error' : ''}
|
onBlur={() => handleBlur(teamMember.id, teamMember.value)}
|
||||||
id={`member-${index}`}
|
ref={el => inputRefs.current[index] = el}
|
||||||
|
className="border-0 shadow-none"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: token?.colorText
|
||||||
|
}}
|
||||||
|
prefix={<MailOutlined style={{ color: token?.colorTextTertiary }} />}
|
||||||
|
status={emailStatus === 'invalid' ? 'error' : undefined}
|
||||||
|
suffix={
|
||||||
|
emailStatus === 'valid' ? (
|
||||||
|
<CheckCircleOutlined style={{ color: token?.colorSuccess }} />
|
||||||
|
) : emailStatus === 'invalid' ? (
|
||||||
|
<ExclamationCircleOutlined style={{ color: token?.colorError }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
{emailStatus === 'invalid' && (
|
||||||
<Button
|
<Text type="danger" className="text-xs mt-1 block">
|
||||||
className="custom-close-button"
|
{t('invalidEmail')}
|
||||||
style={{ marginLeft: '48px' }}
|
</Text>
|
||||||
type="text"
|
)}
|
||||||
icon={<CloseCircleOutlined />}
|
{emailStatus === 'valid' && (
|
||||||
disabled={teamMembers.length === 1}
|
<Text type="success" className="text-xs mt-1 block">
|
||||||
onClick={() => removeEmail(teamMember.id)}
|
{t('validEmailAddress')}
|
||||||
/>
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{teamMembers.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CloseCircleOutlined />}
|
||||||
|
onClick={() => removeEmail(teamMember.id)}
|
||||||
|
style={{ color: token?.colorTextTertiary }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
);
|
||||||
)}
|
})}
|
||||||
/>
|
</div>
|
||||||
<Button
|
|
||||||
type="dashed"
|
{/* Add Member Button */}
|
||||||
icon={<PlusOutlined />}
|
{teamMembers.length < 5 && (
|
||||||
onClick={addEmail}
|
<Button
|
||||||
style={{ marginTop: '16px' }}
|
type="dashed"
|
||||||
disabled={teamMembers.length == 5}
|
icon={<PlusOutlined />}
|
||||||
>
|
onClick={addEmail}
|
||||||
{t('tasksStepAddAnother')}
|
className="w-full mt-4 h-12 text-base"
|
||||||
</Button>
|
style={{
|
||||||
<div
|
borderColor: token?.colorBorder,
|
||||||
|
color: token?.colorTextSecondary
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('addAnotherTeamMember', { current: teamMembers.length, max: 5 })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip Option */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Alert
|
||||||
|
message={t('canInviteLater')}
|
||||||
|
description={t('skipStepDescription')}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
style={{
|
style={{
|
||||||
marginTop: '24px',
|
backgroundColor: token?.colorInfoBg,
|
||||||
display: 'flex',
|
borderColor: token?.colorInfoBorder
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
}}
|
||||||
></div>
|
/>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,40 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, Input, InputRef, Typography } from 'antd';
|
import { Form, Input, InputRef, Typography, Card, Tooltip } from '@/shared/antd-imports';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||||
import './admin-center-common.css';
|
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
styles: any;
|
styles: any;
|
||||||
organizationNamePlaceholder: string;
|
organizationNamePlaceholder: string;
|
||||||
|
organizationNameInitialValue?: string;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
token?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OrganizationStep: React.FC<Props> = ({
|
export const OrganizationStep: React.FC<Props> = ({
|
||||||
onEnter,
|
onEnter,
|
||||||
styles,
|
styles,
|
||||||
organizationNamePlaceholder,
|
organizationNamePlaceholder,
|
||||||
|
organizationNameInitialValue,
|
||||||
|
isDarkMode,
|
||||||
|
token,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('account-setup');
|
const { t } = useTranslation('account-setup');
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||||
const inputRef = useRef<InputRef>(null);
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
|
// Autofill organization name if not already set
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!organizationName && organizationNameInitialValue) {
|
||||||
|
dispatch(setOrganizationName(organizationNameInitialValue));
|
||||||
|
}
|
||||||
setTimeout(() => inputRef.current?.focus(), 300);
|
setTimeout(() => inputRef.current?.focus(), 300);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -40,25 +49,85 @@ export const OrganizationStep: React.FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form className="step-form" style={styles.form}>
|
<div className="w-full organization-step">
|
||||||
<Form.Item>
|
{/* Header */}
|
||||||
<Title level={2} style={{ marginBottom: '1rem' }}>
|
<div className="text-center mb-8">
|
||||||
{t('organizationStepTitle')}
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{t('organizationStepWelcome')}
|
||||||
</Title>
|
</Title>
|
||||||
</Form.Item>
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
<Form.Item
|
{t('organizationStepDescription')}
|
||||||
layout="vertical"
|
</Paragraph>
|
||||||
rules={[{ required: true }]}
|
</div>
|
||||||
label={<span style={styles.label}>{t('organizationStepLabel')}</span>}
|
|
||||||
|
{/* Main Form Card */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Card
|
||||||
|
className="border-2 hover:shadow-md transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
borderColor: token?.colorPrimary,
|
||||||
|
backgroundColor: token?.colorBgContainer
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
className="mb-4"
|
||||||
|
label={
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium text-base" style={{ color: token?.colorText }}>
|
||||||
|
{t('organizationStepLabel')}
|
||||||
|
</span>
|
||||||
|
<Tooltip title={t('organizationStepTooltip')}>
|
||||||
|
<span
|
||||||
|
className="text-sm cursor-help"
|
||||||
|
style={{ color: token?.colorTextTertiary }}
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={organizationNamePlaceholder || t('organizationStepPlaceholder')}
|
||||||
|
value={organizationName}
|
||||||
|
onChange={handleOrgNameChange}
|
||||||
|
onPressEnter={onPressEnter}
|
||||||
|
ref={inputRef}
|
||||||
|
className="text-base"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Character Count and Validation */}
|
||||||
|
<div className="flex justify-between items-center text-sm">
|
||||||
|
<Text type="secondary">
|
||||||
|
{organizationName.length}/50 {t('organizationStepCharacters')}
|
||||||
|
</Text>
|
||||||
|
{organizationName.length > 0 && (
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{organizationName.length >= 2 ? (
|
||||||
|
<span style={{ color: token?.colorSuccess }}>✓ {t('organizationStepGoodLength')}</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: token?.colorWarning }}>⚠ {t('organizationStepTooShort')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Note */}
|
||||||
|
<div
|
||||||
|
className="text-center p-4 rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: token?.colorInfoBg,
|
||||||
|
borderColor: token?.colorInfoBorder,
|
||||||
|
border: '1px solid'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Text type="secondary" className="text-sm">
|
||||||
placeholder={organizationNamePlaceholder}
|
🔒 {t('organizationStepPrivacyNote')}
|
||||||
value={organizationName}
|
</Text>
|
||||||
onChange={handleOrgNameChange}
|
</div>
|
||||||
onPressEnter={onPressEnter}
|
</div>
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { Button, Drawer, Form, Input, InputRef, Select, Typography } from 'antd';
|
import { Button, Drawer, Form, Input, InputRef, Typography, Card, Row, Col, Tag, Tooltip, Spin, Alert } from '@/shared/antd-imports';
|
||||||
import TemplateDrawer from '../common/template-drawer/template-drawer';
|
import TemplateDrawer from '../common/template-drawer/template-drawer';
|
||||||
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
@@ -13,7 +13,7 @@ import { sanitizeInput } from '@/utils/sanitizeInput';
|
|||||||
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
|
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
|
import { IAccountSetupRequest, IWorklenzTemplate, IProjectTemplate } from '@/types/project-templates/project-templates.types';
|
||||||
|
|
||||||
import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events';
|
import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
@@ -24,15 +24,43 @@ import { setUser } from '@/features/user/userSlice';
|
|||||||
import { setSession } from '@/utils/session-helper';
|
import { setSession } from '@/utils/session-helper';
|
||||||
import { IAuthorizeResponse } from '@/types/auth/login.types';
|
import { IAuthorizeResponse } from '@/types/auth/login.types';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
styles: any;
|
styles: any;
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
|
token?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = false }) => {
|
// Default icon mapping for templates (fallback if no image_url)
|
||||||
|
const getTemplateIcon = (name?: string) => {
|
||||||
|
if (!name) return '📁';
|
||||||
|
const lowercaseName = name.toLowerCase();
|
||||||
|
if (lowercaseName.includes('software') || lowercaseName.includes('development')) return '💻';
|
||||||
|
if (lowercaseName.includes('marketing') || lowercaseName.includes('campaign')) return '📢';
|
||||||
|
if (lowercaseName.includes('construction') || lowercaseName.includes('building')) return '🏗️';
|
||||||
|
if (lowercaseName.includes('startup') || lowercaseName.includes('launch')) return '🚀';
|
||||||
|
if (lowercaseName.includes('design') || lowercaseName.includes('creative')) return '🎨';
|
||||||
|
if (lowercaseName.includes('education') || lowercaseName.includes('learning')) return '📚';
|
||||||
|
if (lowercaseName.includes('event') || lowercaseName.includes('planning')) return '📅';
|
||||||
|
if (lowercaseName.includes('retail') || lowercaseName.includes('sales')) return '🛍️';
|
||||||
|
return '📁';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectSuggestions = (orgType?: string) => {
|
||||||
|
const suggestions: Record<string, string[]> = {
|
||||||
|
'freelancer': ['Client Website', 'Logo Design', 'Content Writing', 'App Development'],
|
||||||
|
'startup': ['MVP Development', 'Product Launch', 'Marketing Campaign', 'Investor Pitch'],
|
||||||
|
'small_medium_business': ['Q1 Sales Initiative', 'Website Redesign', 'Process Improvement', 'Team Training'],
|
||||||
|
'agency': ['Client Campaign', 'Brand Strategy', 'Website Project', 'Creative Brief'],
|
||||||
|
'enterprise': ['Digital Transformation', 'System Migration', 'Annual Planning', 'Department Initiative'],
|
||||||
|
'other': ['New Project', 'Team Initiative', 'Q1 Goals', 'Special Project']
|
||||||
|
};
|
||||||
|
return suggestions[orgType || 'other'] || suggestions['other'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = false, token }) => {
|
||||||
const { t } = useTranslation('account-setup');
|
const { t } = useTranslation('account-setup');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -42,13 +70,58 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => inputRef.current?.focus(), 200);
|
setTimeout(() => inputRef.current?.focus(), 200);
|
||||||
|
fetchTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { projectName, templateId, organizationName } = useSelector(
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
setLoadingTemplates(true);
|
||||||
|
setTemplateError(null);
|
||||||
|
|
||||||
|
// Fetch list of available templates
|
||||||
|
const templatesResponse = await projectTemplatesApiService.getWorklenzTemplates();
|
||||||
|
|
||||||
|
if (templatesResponse.done && templatesResponse.body) {
|
||||||
|
// Fetch detailed information for first 4 templates for preview
|
||||||
|
const templateDetails = await Promise.all(
|
||||||
|
templatesResponse.body.slice(0, 4).map(async (template) => {
|
||||||
|
if (template.id) {
|
||||||
|
try {
|
||||||
|
const detailResponse = await projectTemplatesApiService.getByTemplateId(template.id);
|
||||||
|
return detailResponse.done ? detailResponse.body : null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch template details for ${template.id}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter out null results and set templates
|
||||||
|
const validTemplates = templateDetails.filter((template): template is IProjectTemplate => template !== null);
|
||||||
|
setTemplates(validTemplates);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch templates', error);
|
||||||
|
setTemplateError('Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const { projectName, templateId, organizationName, surveyData } = useSelector(
|
||||||
(state: RootState) => state.accountSetupReducer
|
(state: RootState) => state.accountSetupReducer
|
||||||
);
|
);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [creatingFromTemplate, setCreatingFromTemplate] = useState(false);
|
const [creatingFromTemplate, setCreatingFromTemplate] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(templateId || null);
|
||||||
|
const [templates, setTemplates] = useState<IProjectTemplate[]>([]);
|
||||||
|
const [loadingTemplates, setLoadingTemplates] = useState(true);
|
||||||
|
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const projectSuggestions = getProjectSuggestions(surveyData.organization_type);
|
||||||
|
|
||||||
const handleTemplateSelected = (templateId: string) => {
|
const handleTemplateSelected = (templateId: string) => {
|
||||||
if (!templateId) return;
|
if (!templateId) return;
|
||||||
@@ -74,8 +147,6 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
if (res.done && res.body.id) {
|
if (res.done && res.body.id) {
|
||||||
toggleTemplateSelector(false);
|
toggleTemplateSelector(false);
|
||||||
trackMixpanelEvent(evt_account_setup_template_complete);
|
trackMixpanelEvent(evt_account_setup_template_complete);
|
||||||
|
|
||||||
// Refresh user session to update setup_completed status
|
|
||||||
try {
|
try {
|
||||||
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
|
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
|
||||||
if (authResponse?.authenticated && authResponse?.user) {
|
if (authResponse?.authenticated && authResponse?.user) {
|
||||||
@@ -85,7 +156,6 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to refresh user session after template setup completion', error);
|
logger.error('Failed to refresh user session after template setup completion', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -94,8 +164,7 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onPressEnter = () => {
|
const onPressEnter = () => {
|
||||||
if (!projectName.trim()) return;
|
if (projectName.trim()) onEnter();
|
||||||
onEnter();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleProjectNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -103,43 +172,205 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
dispatch(setProjectName(sanitizedValue));
|
dispatch(setProjectName(sanitizedValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProjectNameFocus = () => {
|
||||||
|
if (templateId) {
|
||||||
|
dispatch(setTemplateId(null));
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion: string) => {
|
||||||
|
dispatch(setProjectName(suggestion));
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="w-full project-step">
|
||||||
<Form className="step-form" style={styles.form}>
|
{/* Header */}
|
||||||
<Form.Item>
|
<div className="text-center mb-8">
|
||||||
<Title level={2} style={{ marginBottom: '1rem' }}>
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
{t('projectStepTitle')}
|
{t('projectStepHeader')}
|
||||||
</Title>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
layout="vertical"
|
|
||||||
rules={[{ required: true }]}
|
|
||||||
label={<span style={styles.label}>{t('projectStepLabel')}</span>}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder={t('projectStepPlaceholder')}
|
|
||||||
value={projectName}
|
|
||||||
onChange={handleProjectNameChange}
|
|
||||||
onPressEnter={onPressEnter}
|
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<Title level={4} className={isDarkMode ? 'vert-text-dark' : 'vert-text'}>
|
|
||||||
{t('or')}
|
|
||||||
</Title>
|
</Title>
|
||||||
<div className={isDarkMode ? 'vert-line-dark' : 'vert-line'} />
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
|
{t('projectStepSubheader')}
|
||||||
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
{/* Project Name Section */}
|
||||||
<Button onClick={() => toggleTemplateSelector(true)} type="primary">
|
<div className="mb-8">
|
||||||
{t('templateButton')}
|
<Card
|
||||||
</Button>
|
className={`border-2 hover:shadow-md transition-all duration-200 ${
|
||||||
|
templateId ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: templateId ? token?.colorBorder : token?.colorPrimary,
|
||||||
|
backgroundColor: token?.colorBgContainer
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Text strong className="text-lg" style={{ color: token?.colorText }}>
|
||||||
|
{t('startFromScratch')}
|
||||||
|
</Text>
|
||||||
|
{templateId && (
|
||||||
|
<Text type="secondary" className="text-sm">
|
||||||
|
{t('templateSelected')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
className="mb-4"
|
||||||
|
label={<span className="font-medium" style={{ color: token?.colorText }}>{t('projectStepLabel')}</span>}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
size="large"
|
||||||
|
placeholder={projectSuggestions[0] || t('projectStepPlaceholder')}
|
||||||
|
value={projectName}
|
||||||
|
onChange={handleProjectNameChange}
|
||||||
|
onPressEnter={onPressEnter}
|
||||||
|
onFocus={handleProjectNameFocus}
|
||||||
|
ref={inputRef}
|
||||||
|
className="text-base"
|
||||||
|
style={{ backgroundColor: token?.colorBgContainer, borderColor: token?.colorBorder, color: token?.colorText }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" className="text-sm">{t('quickSuggestions')}</Text>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{projectSuggestions.map((suggestion, index) => (
|
||||||
|
<button key={index} onClick={() => handleSuggestionClick(suggestion)} className="px-3 py-1 rounded-full text-sm border project-suggestion-button" style={{ backgroundColor: token?.colorBgContainer, borderColor: token?.colorBorder, color: token?.colorTextSecondary }}>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative my-8">
|
||||||
|
<div className="absolute inset-0 flex items-center" style={{ color: token?.colorTextQuaternary }}>
|
||||||
|
<div className="w-full border-t" style={{ borderColor: token?.colorBorder }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="px-4 text-sm font-medium" style={{ backgroundColor: token?.colorBgLayout, color: token?.colorTextSecondary }}>{t('orText')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<Title level={4} className="mb-2" style={{ color: token?.colorText }}>{t('startWithTemplate')}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{t('templateHeadStart')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Preview Cards */}
|
||||||
|
<div className="mb-6">
|
||||||
|
{loadingTemplates ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Spin size="large" />
|
||||||
|
<div className="mt-4">
|
||||||
|
<Text type="secondary">Loading templates...</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : templateError ? (
|
||||||
|
<Alert
|
||||||
|
message="Failed to load templates"
|
||||||
|
description={templateError}
|
||||||
|
type="error"
|
||||||
|
showIcon
|
||||||
|
action={
|
||||||
|
<Button size="small" onClick={fetchTemplates}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Col xs={24} sm={12} key={template.id}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
className={`h-full template-preview-card ${
|
||||||
|
selectedTemplate === template.id ? 'selected border-2' : ''
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
borderColor: selectedTemplate === template.id ? token?.colorPrimary : token?.colorBorder,
|
||||||
|
backgroundColor: token?.colorBgContainer
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplate(template.id || null);
|
||||||
|
dispatch(setTemplateId(template.id || ''));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
{template.image_url ? (
|
||||||
|
<img
|
||||||
|
src={template.image_url}
|
||||||
|
alt={template.name}
|
||||||
|
className="w-12 h-12 object-cover rounded"
|
||||||
|
onError={(e) => {
|
||||||
|
// Fallback to icon if image fails to load
|
||||||
|
e.currentTarget.style.display = 'none';
|
||||||
|
if (e.currentTarget.nextSibling) {
|
||||||
|
(e.currentTarget.nextSibling as HTMLElement).style.display = 'block';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className="text-3xl"
|
||||||
|
style={{ display: template.image_url ? 'none' : 'block' }}
|
||||||
|
>
|
||||||
|
{getTemplateIcon(template.name)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Text strong className="block mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{template.name || 'Untitled Template'}
|
||||||
|
</Text>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{template.phases?.slice(0, 3).map((phase, index) => (
|
||||||
|
<Tag key={index} color={phase.color_code || 'blue'} className="text-xs">
|
||||||
|
{phase.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{(template.phases?.length || 0) > 3 && (
|
||||||
|
<Tag className="text-xs">+{(template.phases?.length || 0) - 3} more</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Button type="primary" size="large" icon={<span className="mr-2">🎨</span>} onClick={() => toggleTemplateSelector(true)} className="min-w-[200px]">{t('browseAllTemplates')}</Button>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Text type="secondary" className="text-sm">{t('templatesAvailable')}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Drawer */}
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<Drawer
|
<Drawer
|
||||||
title={t('templateDrawerTitle')}
|
title={
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: 0 }}>
|
||||||
|
{t('templateDrawerTitle')}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
{t('chooseTemplate')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
width={1000}
|
width={1000}
|
||||||
onClose={() => toggleTemplateSelector(false)}
|
onClose={() => toggleTemplateSelector(false)}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -152,11 +383,13 @@ export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = fal
|
|||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => createFromTemplate()}
|
onClick={() => createFromTemplate()}
|
||||||
loading={creatingFromTemplate}
|
loading={creatingFromTemplate}
|
||||||
|
disabled={!templateId}
|
||||||
>
|
>
|
||||||
{t('create')}
|
{t('createProject')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
style={{ backgroundColor: token?.colorBgLayout }}
|
||||||
>
|
>
|
||||||
<TemplateDrawer
|
<TemplateDrawer
|
||||||
showBothTabs={false}
|
showBothTabs={false}
|
||||||
|
|||||||
368
worklenz-frontend/src/components/account-setup/survey-step.tsx
Normal file
368
worklenz-frontend/src/components/account-setup/survey-step.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Form, Input, Typography, Button, Progress, Space } from '@/shared/antd-imports';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { setSurveyData, setSurveySubStep } from '@/features/account-setup/account-setup.slice';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import {
|
||||||
|
OrganizationType,
|
||||||
|
UserRole,
|
||||||
|
UseCase,
|
||||||
|
HowHeardAbout,
|
||||||
|
IAccountSetupSurveyData
|
||||||
|
} from '@/types/account-setup/survey.types';
|
||||||
|
|
||||||
|
const { Title, Paragraph } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onEnter: () => void;
|
||||||
|
styles: any;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
token?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SurveyPageProps {
|
||||||
|
styles: any;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
token?: any;
|
||||||
|
surveyData: IAccountSetupSurveyData;
|
||||||
|
handleSurveyDataChange: (field: keyof IAccountSetupSurveyData, value: any) => void;
|
||||||
|
handleUseCaseToggle?: (value: UseCase) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 1: About You
|
||||||
|
const AboutYouPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange }) => {
|
||||||
|
const { t } = useTranslation('account-setup');
|
||||||
|
|
||||||
|
const organizationTypeOptions: { value: OrganizationType; label: string; icon?: string }[] = [
|
||||||
|
{ value: 'freelancer', label: t('organizationTypeFreelancer'), icon: '👤' },
|
||||||
|
{ value: 'startup', label: t('organizationTypeStartup'), icon: '🚀' },
|
||||||
|
{ value: 'small_medium_business', label: t('organizationTypeSmallMediumBusiness'), icon: '🏢' },
|
||||||
|
{ value: 'agency', label: t('organizationTypeAgency'), icon: '🎯' },
|
||||||
|
{ value: 'enterprise', label: t('organizationTypeEnterprise'), icon: '🏛️' },
|
||||||
|
{ value: 'other', label: t('organizationTypeOther'), icon: '📋' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const userRoleOptions: { value: UserRole; label: string; icon?: string }[] = [
|
||||||
|
{ value: 'founder_ceo', label: t('userRoleFounderCeo'), icon: '👔' },
|
||||||
|
{ value: 'project_manager', label: t('userRoleProjectManager'), icon: '📊' },
|
||||||
|
{ value: 'software_developer', label: t('userRoleSoftwareDeveloper'), icon: '💻' },
|
||||||
|
{ value: 'designer', label: t('userRoleDesigner'), icon: '🎨' },
|
||||||
|
{ value: 'operations', label: t('userRoleOperations'), icon: '⚙️' },
|
||||||
|
{ value: 'other', label: t('userRoleOther'), icon: '✋' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{t('aboutYouStepTitle')}
|
||||||
|
</Title>
|
||||||
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
|
{t('aboutYouStepDescription')}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organization Type */}
|
||||||
|
<Form.Item className="mb-8">
|
||||||
|
<label className="block font-medium text-base mb-4" style={{ color: token?.colorText }}>
|
||||||
|
{t('orgTypeQuestion')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||||
|
{organizationTypeOptions.map((option) => {
|
||||||
|
const isSelected = surveyData.organization_type === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleSurveyDataChange('organization_type', option.value)}
|
||||||
|
className={`p-2 rounded border transition-all duration-200 text-left hover:shadow-sm ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? undefined : token?.colorBgContainer,
|
||||||
|
borderColor: isSelected ? undefined : token?.colorBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full border flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||||
|
{isSelected && <div className="w-1.5 h-1.5 bg-white rounded-full"></div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-base">{option.icon}</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium text-xs ${isSelected ? 'text-blue-600 dark:text-blue-400' : ''}`}
|
||||||
|
style={{ color: isSelected ? undefined : token?.colorText }}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* User Role */}
|
||||||
|
<Form.Item className="mb-4">
|
||||||
|
<label className="block font-medium text-base mb-4" style={{ color: token?.colorText }}>
|
||||||
|
{t('userRoleQuestion')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||||
|
{userRoleOptions.map((option) => {
|
||||||
|
const isSelected = surveyData.user_role === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleSurveyDataChange('user_role', option.value)}
|
||||||
|
className={`p-2 rounded border transition-all duration-200 text-left hover:shadow-sm ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? undefined : token?.colorBgContainer,
|
||||||
|
borderColor: isSelected ? undefined : token?.colorBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full border flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||||
|
{isSelected && <div className="w-1.5 h-1.5 bg-white rounded-full"></div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-base">{option.icon}</span>
|
||||||
|
<span
|
||||||
|
className={`font-medium text-xs ${isSelected ? 'text-blue-600 dark:text-blue-400' : ''}`}
|
||||||
|
style={{ color: isSelected ? undefined : token?.colorText }}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page 2: Your Needs
|
||||||
|
const YourNeedsPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange, handleUseCaseToggle }) => {
|
||||||
|
const { t } = useTranslation('account-setup');
|
||||||
|
|
||||||
|
const useCaseOptions: { value: UseCase; label: string; description: string }[] = [
|
||||||
|
{ value: 'task_management', label: t('mainUseCasesTaskManagement'), description: 'Organize and track tasks' },
|
||||||
|
{ value: 'team_collaboration', label: t('mainUseCasesTeamCollaboration'), description: 'Work together seamlessly' },
|
||||||
|
{ value: 'resource_planning', label: t('mainUseCasesResourcePlanning'), description: 'Manage time and resources' },
|
||||||
|
{ value: 'client_communication', label: t('mainUseCasesClientCommunication'), description: 'Stay connected with clients' },
|
||||||
|
{ value: 'time_tracking', label: t('mainUseCasesTimeTracking'), description: 'Monitor project hours' },
|
||||||
|
{ value: 'other', label: t('mainUseCasesOther'), description: 'Something else' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onUseCaseClick = (value: UseCase) => {
|
||||||
|
if (handleUseCaseToggle) {
|
||||||
|
handleUseCaseToggle(value);
|
||||||
|
} else {
|
||||||
|
const currentUseCases = surveyData.main_use_cases || [];
|
||||||
|
const isSelected = currentUseCases.includes(value);
|
||||||
|
const newUseCases = isSelected ? currentUseCases.filter(useCase => useCase !== value) : [...currentUseCases, value];
|
||||||
|
handleSurveyDataChange('main_use_cases', newUseCases);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{t('yourNeedsStepTitle')}
|
||||||
|
</Title>
|
||||||
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
|
{t('yourNeedsStepDescription')}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Use Cases */}
|
||||||
|
<Form.Item className="mb-8">
|
||||||
|
<label className="block font-medium text-base mb-4" style={{ color: token?.colorText }}>
|
||||||
|
{t('yourNeedsQuestion')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-1">
|
||||||
|
{useCaseOptions.map((option) => {
|
||||||
|
const isSelected = (surveyData.main_use_cases || []).includes(option.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onUseCaseClick(option.value)}
|
||||||
|
className={`p-2 rounded border transition-all duration-200 text-left hover:shadow-sm ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? undefined : token?.colorBgContainer,
|
||||||
|
borderColor: isSelected ? undefined : token?.colorBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded border flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||||
|
{isSelected && (
|
||||||
|
<svg width="10" height="10" fill="white" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className={`font-medium text-xs ${isSelected ? 'text-blue-600 dark:text-blue-400' : ''}`} style={{ color: isSelected ? undefined : token?.colorText }}>{option.label}</span>
|
||||||
|
<span className="text-xs ml-2" style={{ color: token?.colorTextSecondary }}>- {option.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{surveyData.main_use_cases && surveyData.main_use_cases.length > 0 && (
|
||||||
|
<p className="mt-3 text-sm" style={{ color: token?.colorTextSecondary }}>
|
||||||
|
{surveyData.main_use_cases.length} {t('selected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Previous Tools */}
|
||||||
|
<Form.Item className="mb-4">
|
||||||
|
<label className="block font-medium text-base mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{t('previousToolsLabel')}
|
||||||
|
</label>
|
||||||
|
<TextArea
|
||||||
|
placeholder="e.g., Asana, Trello, Jira, Monday.com, etc."
|
||||||
|
value={surveyData.previous_tools || ''}
|
||||||
|
onChange={(e) => handleSurveyDataChange('previous_tools', e.target.value)}
|
||||||
|
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||||
|
className="text-base"
|
||||||
|
style={{ backgroundColor: token?.colorBgContainer, borderColor: token?.colorBorder, color: token?.colorText }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Page 3: Discovery
|
||||||
|
const DiscoveryPage: React.FC<SurveyPageProps> = ({ styles, token, surveyData, handleSurveyDataChange }) => {
|
||||||
|
const { t } = useTranslation('account-setup');
|
||||||
|
|
||||||
|
const howHeardAboutOptions: { value: HowHeardAbout; label: string; icon: string }[] = [
|
||||||
|
{ value: 'google_search', label: t('howHeardAboutGoogleSearch'), icon: '🔍' },
|
||||||
|
{ value: 'twitter', label: t('howHeardAboutTwitter'), icon: '🐦' },
|
||||||
|
{ value: 'linkedin', label: t('howHeardAboutLinkedin'), icon: '💼' },
|
||||||
|
{ value: 'friend_colleague', label: t('howHeardAboutFriendColleague'), icon: '👥' },
|
||||||
|
{ value: 'blog_article', label: t('howHeardAboutBlogArticle'), icon: '📰' },
|
||||||
|
{ value: 'other', label: t('howHeardAboutOther'), icon: '💡' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
|
{t('discoveryTitle')}
|
||||||
|
</Title>
|
||||||
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
|
{t('discoveryDescription')}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* How Heard About */}
|
||||||
|
<Form.Item className="mb-8">
|
||||||
|
<label className="block font-medium text-base mb-4" style={{ color: token?.colorText }}>
|
||||||
|
{t('discoveryQuestion')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||||
|
{howHeardAboutOptions.map((option) => {
|
||||||
|
const isSelected = surveyData.how_heard_about === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => handleSurveyDataChange('how_heard_about', option.value)}
|
||||||
|
className={`p-2 rounded border transition-all duration-200 hover:shadow-sm ${isSelected ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSelected ? undefined : token?.colorBgContainer,
|
||||||
|
borderColor: isSelected ? undefined : token?.colorBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full border flex items-center justify-center ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||||
|
{isSelected && <div className="w-1.5 h-1.5 bg-white rounded-full"></div>}
|
||||||
|
</div>
|
||||||
|
<span className="text-base">{option.icon}</span>
|
||||||
|
<span className={`font-medium text-xs ${isSelected ? 'text-blue-600 dark:text-blue-400' : ''}`} style={{ color: isSelected ? undefined : token?.colorText }}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<div className="mt-12 p-1.5 rounded-lg text-center" style={{ backgroundColor: token?.colorSuccessBg, borderColor: token?.colorSuccessBorder, border: '1px solid' }}>
|
||||||
|
<div className="text-4xl mb-3">🎉</div>
|
||||||
|
<Title level={4} style={{ color: token?.colorText, marginBottom: 8 }}>{t('allSetTitle')}</Title>
|
||||||
|
<Paragraph style={{ color: token?.colorTextSecondary, marginBottom: 0 }}>{t('allSetDescription')}</Paragraph>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SurveyStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
|
||||||
|
const { t } = useTranslation('account-setup');
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { surveyData, surveySubStep } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||||
|
|
||||||
|
const handleSurveyDataChange = (field: keyof IAccountSetupSurveyData, value: any) => {
|
||||||
|
dispatch(setSurveyData({ [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyPress = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const isValid = (surveySubStep === 0 && surveyData.organization_type && surveyData.user_role) || (surveySubStep === 1 && surveyData.main_use_cases && surveyData.main_use_cases.length > 0) || (surveySubStep === 2 && surveyData.how_heard_about);
|
||||||
|
if (isValid && surveySubStep < 2) {
|
||||||
|
dispatch(setSurveySubStep(surveySubStep + 1));
|
||||||
|
} else if (isValid && surveySubStep === 2) {
|
||||||
|
onEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keypress', handleKeyPress);
|
||||||
|
return () => window.removeEventListener('keypress', handleKeyPress);
|
||||||
|
}, [surveySubStep, surveyData, dispatch, onEnter]);
|
||||||
|
|
||||||
|
const handleUseCaseToggle = (value: UseCase) => {
|
||||||
|
const currentUseCases = surveyData.main_use_cases || [];
|
||||||
|
const isSelected = currentUseCases.includes(value);
|
||||||
|
const newUseCases = isSelected ? currentUseCases.filter(useCase => useCase !== value) : [...currentUseCases, value];
|
||||||
|
handleSurveyDataChange('main_use_cases', newUseCases);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubStepTitle = () => {
|
||||||
|
switch (surveySubStep) {
|
||||||
|
case 0: return t('aboutYouStepName');
|
||||||
|
case 1: return t('yourNeedsStepName');
|
||||||
|
case 2: return t('discoveryStepName');
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const surveyPages = [
|
||||||
|
<AboutYouPage key="about-you" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />,
|
||||||
|
<YourNeedsPage key="your-needs" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} handleUseCaseToggle={handleUseCaseToggle} />,
|
||||||
|
<DiscoveryPage key="discovery" styles={styles} isDarkMode={isDarkMode} token={token} surveyData={surveyData} handleSurveyDataChange={handleSurveyDataChange} />
|
||||||
|
];
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
dispatch(setSurveySubStep(0));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium" style={{ color: token?.colorTextSecondary }}>Step {surveySubStep + 1} of 3: {getSubStepTitle()}</span>
|
||||||
|
<span className="text-sm" style={{ color: token?.colorTextSecondary }}>{Math.round(((surveySubStep + 1) / 3) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress percent={Math.round(((surveySubStep + 1) / 3) * 100)} showInfo={false} strokeColor={token?.colorPrimary} className="mb-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Page Content */}
|
||||||
|
<div className="min-h-[400px] flex flex-col survey-page-transition" key={surveySubStep}>
|
||||||
|
{surveyPages[surveySubStep]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,134 +1,130 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Form, Input, Button, Typography, List, InputRef } from 'antd';
|
import { Input, Button, Typography, Card } from '@/shared/antd-imports';
|
||||||
import { PlusOutlined, DeleteOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DeleteOutlined, CloseCircleOutlined, CheckCircleOutlined } from '@/shared/antd-imports';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { setTasks } from '@/features/account-setup/account-setup.slice';
|
import { setTasks } from '@/features/account-setup/account-setup.slice';
|
||||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Paragraph, Text } = Typography;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onEnter: () => void;
|
onEnter: () => void;
|
||||||
styles: any;
|
styles: any;
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
|
token?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode }) => {
|
|
||||||
|
export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode, token }) => {
|
||||||
const { t } = useTranslation('account-setup');
|
const { t } = useTranslation('account-setup');
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { tasks, projectName } = useSelector((state: RootState) => state.accountSetupReducer);
|
const { tasks, projectName, surveyData } = useSelector((state: RootState) => state.accountSetupReducer);
|
||||||
const inputRefs = useRef<(InputRef | null)[]>([]);
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const addTask = () => {
|
const addTask = () => {
|
||||||
if (tasks.length == 5) return;
|
if (tasks.length >= 5) return;
|
||||||
|
|
||||||
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 0;
|
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 0;
|
||||||
dispatch(setTasks([...tasks, { id: newId, value: '' }]));
|
dispatch(setTasks([...tasks, { id: newId, value: '' }]));
|
||||||
setTimeout(() => {
|
setTimeout(() => inputRefs.current[tasks.length]?.focus(), 100);
|
||||||
inputRefs.current[newId]?.focus();
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTask = (id: number) => {
|
const removeTask = (id: number) => {
|
||||||
if (tasks.length > 1) {
|
if (tasks.length > 1) dispatch(setTasks(tasks.filter(task => task.id !== id)));
|
||||||
dispatch(setTasks(tasks.filter(task => task.id !== id)));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTask = (id: number, value: string) => {
|
const updateTask = (id: number, value: string) => {
|
||||||
const sanitizedValue = sanitizeInput(value);
|
const sanitizedValue = sanitizeInput(value);
|
||||||
dispatch(
|
dispatch(setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task))));
|
||||||
setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task)))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
||||||
const input = e.currentTarget as HTMLInputElement;
|
if (e.key === 'Enter') {
|
||||||
if (!input.value.trim()) return;
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
e.preventDefault();
|
if (input.value.trim()) {
|
||||||
addTask();
|
e.preventDefault();
|
||||||
|
if (index === tasks.length - 1 && tasks.length < 5) addTask();
|
||||||
|
else if (index < tasks.length - 1) inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion: string) => {
|
||||||
|
const emptyTaskIndex = tasks.findIndex(task => !task.value.trim());
|
||||||
|
if (emptyTaskIndex !== -1) {
|
||||||
|
updateTask(tasks[emptyTaskIndex].id, suggestion);
|
||||||
|
} else if (tasks.length < 5) {
|
||||||
|
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 0;
|
||||||
|
dispatch(setTasks([...tasks, { id: newId, value: suggestion }]));
|
||||||
|
}
|
||||||
|
setShowSuggestions(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => inputRefs.current[0]?.focus(), 200);
|
setTimeout(() => inputRefs.current[0]?.focus(), 200);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Function to set ref that doesn't return anything (void)
|
|
||||||
const setInputRef = (index: number) => (el: InputRef | null) => {
|
|
||||||
inputRefs.current[index] = el;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div className="w-full tasks-step">
|
||||||
className="create-first-task-form"
|
{/* Header */}
|
||||||
style={{
|
<div className="text-center mb-8">
|
||||||
minHeight: '300px',
|
<Title level={3} className="mb-2" style={{ color: token?.colorText }}>
|
||||||
width: '600px',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
marginBottom: '3rem',
|
|
||||||
marginTop: '3rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item>
|
|
||||||
<Title level={2} style={{ marginBottom: '1rem' }}>
|
|
||||||
{t('tasksStepTitle')}
|
{t('tasksStepTitle')}
|
||||||
</Title>
|
</Title>
|
||||||
</Form.Item>
|
<Paragraph className="text-base" style={{ color: token?.colorTextSecondary }}>
|
||||||
<Form.Item
|
{t('tasksStepDescription', { projectName })}
|
||||||
layout="vertical"
|
</Paragraph>
|
||||||
rules={[{ required: true }]}
|
</div>
|
||||||
label={
|
|
||||||
<span className="font-medium">
|
|
||||||
{t('tasksStepLabel')} "<mark>{projectName}</mark>". {t('maxTasks')}
|
{/* Tasks List */}
|
||||||
</span>
|
<div className="mb-6">
|
||||||
}
|
<div className="space-y-4">
|
||||||
>
|
{tasks.map((task, index) => (
|
||||||
<List
|
<Card
|
||||||
dataSource={tasks}
|
key={task.id}
|
||||||
bordered={false}
|
className={`task-item-card transition-all duration-200 ${
|
||||||
renderItem={(task, index) => (
|
focusedIndex === index ? 'border-2' : ''
|
||||||
<List.Item key={task.id}>
|
}`}
|
||||||
<div style={{ display: 'flex', width: '600px' }}>
|
style={{
|
||||||
<Input
|
borderColor: focusedIndex === index ? token?.colorPrimary : token?.colorBorder,
|
||||||
placeholder="Your Task"
|
backgroundColor: token?.colorBgContainer
|
||||||
value={task.value}
|
}}
|
||||||
onChange={e => updateTask(task.id, e.target.value)}
|
>
|
||||||
onPressEnter={handleKeyPress}
|
<div className="flex items-center space-x-3">
|
||||||
ref={setInputRef(index)}
|
<div className="flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium" style={{ backgroundColor: task.value.trim() ? token?.colorSuccess : token?.colorBorderSecondary, color: task.value.trim() ? '#fff' : token?.colorTextSecondary }}>
|
||||||
/>
|
{task.value.trim() ? <CheckCircleOutlined /> : index + 1}
|
||||||
<Button
|
</div>
|
||||||
className="custom-close-button"
|
|
||||||
style={{ marginLeft: '48px' }}
|
<div className="flex-1">
|
||||||
type="text"
|
<Input
|
||||||
icon={<CloseCircleOutlined />}
|
placeholder={t('taskPlaceholder', { index: index + 1 })}
|
||||||
disabled={tasks.length === 1}
|
value={task.value}
|
||||||
onClick={() => removeTask(task.id)}
|
onChange={e => updateTask(task.id, e.target.value)}
|
||||||
/>
|
onKeyPress={e => handleKeyPress(e, index)}
|
||||||
|
onFocus={() => setFocusedIndex(index)}
|
||||||
|
onBlur={() => setFocusedIndex(null)}
|
||||||
|
ref={(el) => { inputRefs.current[index] = el as any; }}
|
||||||
|
className="text-base border-0 shadow-none task-input"
|
||||||
|
style={{ backgroundColor: 'transparent', color: token?.colorText }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tasks.length > 1 && <Button type="text" icon={<CloseCircleOutlined />} onClick={() => removeTask(task.id)} className="text-gray-400 hover:text-red-500" style={{ color: token?.colorTextTertiary }} />}
|
||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</Card>
|
||||||
)}
|
))}
|
||||||
/>
|
</div>
|
||||||
<Button
|
|
||||||
type="dashed"
|
{tasks.length < 5 && (
|
||||||
icon={<PlusOutlined />}
|
<Button type="dashed" icon={<PlusOutlined />} onClick={addTask} className="w-full mt-4 h-12 text-base" style={{ borderColor: token?.colorBorder, color: token?.colorTextSecondary }}>{t('addAnotherTask', { current: tasks.length, max: 5 })}</Button>
|
||||||
onClick={addTask}
|
)}
|
||||||
disabled={tasks.length == 5}
|
</div>
|
||||||
style={{ marginTop: '16px' }}
|
|
||||||
>
|
</div>
|
||||||
{t('tasksStepAddAnother')}
|
|
||||||
</Button>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '24px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
|
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from '@/shared/antd-imports';
|
||||||
import { UserAddOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
import { UserAddOutlined, UsergroupAddOutlined } from '@/shared/antd-imports';
|
||||||
import './add-members-dropdown.css';
|
import './add-members-dropdown.css';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { AvatarNamesMap } from '@/shared/constants';
|
import { AvatarNamesMap } from '@/shared/constants';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
|
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from '@/shared/antd-imports';
|
||||||
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
import { PlusOutlined, UsergroupAddOutlined } from '@/shared/antd-imports';
|
||||||
import './add-members-dropdown.css';
|
import './add-members-dropdown.css';
|
||||||
import { AvatarNamesMap } from '../../shared/constants';
|
import { AvatarNamesMap } from '../../shared/constants';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
|||||||
@@ -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 { IBillingCharge, IBillingChargesResponse } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { formatDate } from '@/utils/timeUtils';
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -27,19 +27,19 @@ const ChargesTable: React.FC = () => {
|
|||||||
|
|
||||||
const columns: TableProps<IBillingCharge>['columns'] = [
|
const columns: TableProps<IBillingCharge>['columns'] = [
|
||||||
{
|
{
|
||||||
title: t('description'),
|
title: t('description') as string,
|
||||||
key: 'name',
|
key: 'name',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('billingPeriod'),
|
title: t('billingPeriod') as string,
|
||||||
key: 'billingPeriod',
|
key: 'billingPeriod',
|
||||||
render: record => {
|
render: record => {
|
||||||
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
|
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('billStatus'),
|
title: t('billStatus') as string,
|
||||||
key: 'status',
|
key: 'status',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
@@ -55,7 +55,7 @@ const ChargesTable: React.FC = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('perUserValue'),
|
title: t('perUserValue') as string,
|
||||||
key: 'perUserValue',
|
key: 'perUserValue',
|
||||||
dataIndex: 'perUserValue',
|
dataIndex: 'perUserValue',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
@@ -65,12 +65,12 @@ const ChargesTable: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('users'),
|
title: t('users') as string,
|
||||||
key: 'quantity',
|
key: 'quantity',
|
||||||
dataIndex: 'quantity',
|
dataIndex: 'quantity',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('amount'),
|
title: t('amount') as string,
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { adminCenterApiService } from '@/api/admin-center/admin-center.api.servi
|
|||||||
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
|
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { formatDate } from '@/utils/timeUtils';
|
import { formatDate } from '@/utils/timeUtils';
|
||||||
import { ContainerOutlined } from '@ant-design/icons';
|
import { ContainerOutlined } from '@/shared/antd-imports';
|
||||||
import { Button, Table, TableProps, Tag, Tooltip } from 'antd';
|
import { Button, Table, TableProps, Tag, Tooltip } from '@/shared/antd-imports';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Card, Col, Row, Tooltip, Typography } from 'antd';
|
import { Card, Col, Row, Tooltip } from '@/shared/antd-imports';
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||||
import './current-bill.css';
|
import './current-bill.css';
|
||||||
import { InfoCircleTwoTone } from '@ant-design/icons';
|
import { InfoCircleTwoTone } from '@/shared/antd-imports';
|
||||||
import ChargesTable from './billing-tables/charges-table';
|
import ChargesTable from './billing-tables/charges-table';
|
||||||
import InvoicesTable from './billing-tables/invoices-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 { useAuthService } from '@/hooks/useAuth';
|
||||||
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
|
||||||
|
|
||||||
const CurrentBill: React.FC = () => {
|
const CurrentBill: React.FC = React.memo(() => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('admin-center/current-bill');
|
const { t } = useTranslation('admin-center/current-bill');
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
@@ -32,70 +32,90 @@ const CurrentBill: React.FC = () => {
|
|||||||
dispatch(fetchFreePlanSettings());
|
dispatch(fetchFreePlanSettings());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const titleStyle = {
|
const titleStyle = useMemo(
|
||||||
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
() => ({
|
||||||
fontWeight: 500,
|
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
|
||||||
fontSize: '16px',
|
fontWeight: 500,
|
||||||
display: 'flex',
|
fontSize: '16px',
|
||||||
gap: '4px',
|
display: 'flex',
|
||||||
};
|
gap: '4px',
|
||||||
|
}),
|
||||||
const renderMobileView = () => (
|
[themeMode]
|
||||||
<div>
|
|
||||||
<Col span={24}>
|
|
||||||
<CurrentPlanDetails />
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col span={24} style={{ marginTop: '1.5rem' }}>
|
|
||||||
<AccountStorage themeMode={themeMode} />
|
|
||||||
</Col>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderChargesAndInvoices = () => (
|
const cardStyle = useMemo(() => ({ marginTop: '16px' }), []);
|
||||||
<>
|
const colStyle = useMemo(() => ({ marginTop: '1.5rem' }), []);
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
const tabletColStyle = useMemo(() => ({ paddingRight: '10px' }), []);
|
||||||
<Card
|
const tabletColStyleRight = useMemo(() => ({ paddingLeft: '10px' }), []);
|
||||||
title={
|
|
||||||
<span style={titleStyle}>
|
|
||||||
<span>{t('charges')}</span>
|
|
||||||
<Tooltip title={t('tooltip')}>
|
|
||||||
<InfoCircleTwoTone />
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
style={{ marginTop: '16px' }}
|
|
||||||
>
|
|
||||||
<ChargesTable />
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
const renderMobileView = useCallback(
|
||||||
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={{ marginTop: '16px' }}>
|
() => (
|
||||||
<InvoicesTable />
|
<div>
|
||||||
</Card>
|
<Col span={24}>
|
||||||
|
<CurrentPlanDetails />
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={24} style={colStyle}>
|
||||||
|
<AccountStorage themeMode={themeMode} />
|
||||||
|
</Col>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div style={{ width: '100%' }} className="current-billing">
|
<div style={{ width: '100%' }} className="current-billing">
|
||||||
{isTablet ? (
|
{isTablet ? (
|
||||||
<Row>
|
<Row>
|
||||||
<Col span={16} style={{ paddingRight: '10px' }}>
|
<Col span={16} style={tabletColStyle}>
|
||||||
<CurrentPlanDetails />
|
<CurrentPlanDetails />
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8} style={{ paddingLeft: '10px' }}>
|
<Col span={8} style={tabletColStyleRight}>
|
||||||
<AccountStorage themeMode={themeMode} />
|
<AccountStorage themeMode={themeMode} />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
renderMobileView()
|
renderMobileView()
|
||||||
)}
|
)}
|
||||||
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
{shouldShowChargesAndInvoices && renderChargesAndInvoices()}
|
||||||
renderChargesAndInvoices()}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
CurrentBill.displayName = 'CurrentBill';
|
||||||
|
|
||||||
export default CurrentBill;
|
export default CurrentBill;
|
||||||
|
|||||||
@@ -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 { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
import {
|
import {
|
||||||
evt_billing_pause_plan,
|
evt_billing_pause_plan,
|
||||||
@@ -17,10 +17,9 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Statistic,
|
Statistic,
|
||||||
Select,
|
Select,
|
||||||
Form,
|
|
||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
} from 'antd/es';
|
} from '@/shared/antd-imports';
|
||||||
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
|
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
|
||||||
import {
|
import {
|
||||||
fetchBillingInfo,
|
fetchBillingInfo,
|
||||||
@@ -30,7 +29,7 @@ import {
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||||
import { formatDate } from '@/utils/timeUtils';
|
import { formatDate } from '@/utils/timeUtils';
|
||||||
import UpgradePlansLKR from '../drawers/upgrade-plans-lkr/upgrade-plans-lkr';
|
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 { ISUBSCRIPTION_TYPE, SUBSCRIPTION_STATUS } from '@/shared/constants';
|
||||||
import { billingApiService } from '@/api/admin-center/billing.api.service';
|
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 CurrentPlanDetails = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { t } = useTranslation('admin-center/current-bill');
|
const { t } = useTranslation('admin-center/current-bill');
|
||||||
@@ -47,7 +61,7 @@ const CurrentPlanDetails = () => {
|
|||||||
const [cancellingPlan, setCancellingPlan] = useState(false);
|
const [cancellingPlan, setCancellingPlan] = useState(false);
|
||||||
const [addingSeats, setAddingSeats] = useState(false);
|
const [addingSeats, setAddingSeats] = useState(false);
|
||||||
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = 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 themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
|
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
|
||||||
@@ -56,13 +70,15 @@ const CurrentPlanDetails = () => {
|
|||||||
|
|
||||||
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
type SeatOption = { label: string; value: number | string };
|
const seatCountOptions: SeatOption[] = useMemo(() => {
|
||||||
const seatCountOptions: SeatOption[] = [
|
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,
|
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 }));
|
].map(value => ({ label: value.toString(), value }));
|
||||||
seatCountOptions.push({ label: '100+', value: '100+' });
|
options.push({ label: SEAT_COUNT_LIMIT, value: SEAT_COUNT_LIMIT });
|
||||||
|
return options;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
|
const handleSubscriptionAction = useCallback(async (action: SubscriptionAction) => {
|
||||||
const isResume = action === 'resume';
|
const isResume = action === 'resume';
|
||||||
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
|
||||||
const apiMethod = isResume
|
const apiMethod = isResume
|
||||||
@@ -78,21 +94,21 @@ const CurrentPlanDetails = () => {
|
|||||||
setLoadingState(false);
|
setLoadingState(false);
|
||||||
dispatch(fetchBillingInfo());
|
dispatch(fetchBillingInfo());
|
||||||
trackMixpanelEvent(eventType);
|
trackMixpanelEvent(eventType);
|
||||||
}, 8000);
|
}, BILLING_DELAY_MS);
|
||||||
return; // Exit function to prevent finally block from executing
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error ${action}ing subscription`, 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);
|
setIsMoreSeatsModalVisible(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handlePurchaseMoreSeats = async () => {
|
const handlePurchaseMoreSeats = useCallback(async () => {
|
||||||
if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
|
if (selectedSeatCount.toString() === SEAT_COUNT_LIMIT || !billingInfo?.total_seats) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAddingSeats(true);
|
setAddingSeats(true);
|
||||||
@@ -108,51 +124,75 @@ const CurrentPlanDetails = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setAddingSeats(false);
|
setAddingSeats(false);
|
||||||
}
|
}
|
||||||
};
|
}, [selectedSeatCount, billingInfo?.total_seats, dispatch, trackMixpanelEvent]);
|
||||||
|
|
||||||
const calculateRemainingSeats = () => {
|
const calculateRemainingSeats = useMemo(() => {
|
||||||
if (billingInfo?.total_seats && billingInfo?.total_used) {
|
if (billingInfo?.total_seats && billingInfo?.total_used) {
|
||||||
return billingInfo.total_seats - billingInfo.total_used;
|
return billingInfo.total_seats - billingInfo.total_used;
|
||||||
}
|
}
|
||||||
return 0;
|
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;
|
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
|
||||||
return allowedStatuses.includes(billingInfo.status);
|
return allowedStatuses.includes(billingInfo.status);
|
||||||
};
|
}, [billingInfo?.status, billingInfo?.is_ltd_user]);
|
||||||
|
|
||||||
const shouldShowRedeemButton = () => {
|
const shouldShowRedeemButton = useMemo(() => {
|
||||||
if (billingInfo?.trial_in_progress) return true;
|
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]);
|
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
|
||||||
};
|
}, [checkSubscriptionStatus]);
|
||||||
|
|
||||||
const showPausePlanButton = () => {
|
const showPausePlanButton = useMemo(() => {
|
||||||
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
|
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
|
||||||
};
|
}, [checkSubscriptionStatus]);
|
||||||
|
|
||||||
const showResumePlanButton = () => {
|
const showResumePlanButton = useMemo(() => {
|
||||||
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
|
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
|
||||||
};
|
}, [checkSubscriptionStatus]);
|
||||||
|
|
||||||
const shouldShowAddSeats = () => {
|
const shouldShowAddSeats = useMemo(() => {
|
||||||
if (!billingInfo) return false;
|
if (!billingInfo) return false;
|
||||||
return (
|
return (
|
||||||
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
|
||||||
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
|
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
);
|
);
|
||||||
};
|
}, [billingInfo]);
|
||||||
|
|
||||||
const renderExtra = () => {
|
const renderExtra = useCallback(() => {
|
||||||
if (!billingInfo || billingInfo.is_custom) return null;
|
if (!billingInfo || billingInfo.is_custom) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space>
|
<Space>
|
||||||
{showPausePlanButton() && (
|
{showPausePlanButton && (
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
danger
|
danger
|
||||||
@@ -163,7 +203,7 @@ const CurrentPlanDetails = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showResumePlanButton() && (
|
{showResumePlanButton && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={cancellingPlan}
|
loading={cancellingPlan}
|
||||||
@@ -179,7 +219,7 @@ const CurrentPlanDetails = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showChangeButton() && (
|
{showChangeButton && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={pausingPlan || cancellingPlan}
|
loading={pausingPlan || cancellingPlan}
|
||||||
@@ -190,9 +230,19 @@ const CurrentPlanDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
billingInfo,
|
||||||
|
showPausePlanButton,
|
||||||
|
showResumePlanButton,
|
||||||
|
showChangeButton,
|
||||||
|
pausingPlan,
|
||||||
|
cancellingPlan,
|
||||||
|
handleSubscriptionAction,
|
||||||
|
dispatch,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const renderLtdDetails = () => {
|
const renderLtdDetails = useCallback(() => {
|
||||||
if (!billingInfo || billingInfo.is_custom) return null;
|
if (!billingInfo || billingInfo.is_custom) return null;
|
||||||
return (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
@@ -200,41 +250,41 @@ const CurrentPlanDetails = () => {
|
|||||||
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
|
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
}, [billingInfo, t]);
|
||||||
|
|
||||||
const renderTrialDetails = () => {
|
const checkIfTrialExpired = useCallback(() => {
|
||||||
const checkIfTrialExpired = () => {
|
if (!billingInfo?.trial_expire_date) return false;
|
||||||
if (!billingInfo?.trial_expire_date) return false;
|
const today = new Date();
|
||||||
const today = new Date();
|
today.setHours(0, 0, 0, 0);
|
||||||
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
const trialExpireDate = new Date(billingInfo.trial_expire_date);
|
||||||
const trialExpireDate = new Date(billingInfo.trial_expire_date);
|
trialExpireDate.setHours(0, 0, 0, 0);
|
||||||
trialExpireDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
return today > trialExpireDate;
|
||||||
return today > trialExpireDate;
|
}, [billingInfo?.trial_expire_date]);
|
||||||
};
|
|
||||||
|
|
||||||
const getExpirationMessage = (expireDate: string) => {
|
const getExpirationMessage = useCallback((expireDate: string) => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
const expDate = new Date(expireDate);
|
const expDate = new Date(expireDate);
|
||||||
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
|
expDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
if (expDate.getTime() === today.getTime()) {
|
if (expDate.getTime() === today.getTime()) {
|
||||||
return t('expirestoday', 'today');
|
return t('expirestoday', 'today');
|
||||||
} else if (expDate.getTime() === tomorrow.getTime()) {
|
} else if (expDate.getTime() === tomorrow.getTime()) {
|
||||||
return t('expirestomorrow', 'tomorrow');
|
return t('expirestomorrow', 'tomorrow');
|
||||||
} else if (expDate < today) {
|
} else if (expDate < today) {
|
||||||
const diffTime = Math.abs(today.getTime() - expDate.getTime());
|
const diffTime = Math.abs(today.getTime() - expDate.getTime());
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
|
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
|
||||||
} else {
|
} else {
|
||||||
return calculateTimeGap(expireDate);
|
return calculateTimeGap(expireDate);
|
||||||
}
|
}
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
|
const renderTrialDetails = useCallback(() => {
|
||||||
const isExpired = checkIfTrialExpired();
|
const isExpired = checkIfTrialExpired();
|
||||||
const trialExpireDate = billingInfo?.trial_expire_date || '';
|
const trialExpireDate = billingInfo?.trial_expire_date || '';
|
||||||
|
|
||||||
@@ -257,9 +307,9 @@ const CurrentPlanDetails = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
}, [billingInfo?.trial_expire_date, checkIfTrialExpired, getExpirationMessage, t]);
|
||||||
|
|
||||||
const renderFreePlan = () => (
|
const renderFreePlan = useCallback(() => (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
<Typography.Text strong>{t('freePlan')}</Typography.Text>
|
||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
@@ -271,9 +321,9 @@ const CurrentPlanDetails = () => {
|
|||||||
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
), [freePlanSettings, t]);
|
||||||
|
|
||||||
const renderPaddleSubscriptionInfo = () => {
|
const renderPaddleSubscriptionInfo = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
|
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
|
||||||
@@ -287,14 +337,14 @@ const CurrentPlanDetails = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{shouldShowAddSeats() && billingInfo?.total_seats && (
|
{shouldShowAddSeats && billingInfo?.total_seats && (
|
||||||
<div style={{ marginTop: '16px' }}>
|
<div style={{ marginTop: '16px' }}>
|
||||||
<Row gutter={16} align="middle">
|
<Row gutter={16} align="middle">
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('totalSeats')}
|
title={t('totalSeats') as string}
|
||||||
value={billingInfo.total_seats}
|
value={billingInfo.total_seats}
|
||||||
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
|
valueStyle={STATISTIC_VALUE_STYLE}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
@@ -302,16 +352,16 @@ const CurrentPlanDetails = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={handleAddMoreSeats}
|
onClick={handleAddMoreSeats}
|
||||||
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
|
style={BUTTON_STYLE}
|
||||||
>
|
>
|
||||||
{t('addMoreSeats')}
|
{t('addMoreSeats')}
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('availableSeats')}
|
title={t('availableSeats') as string}
|
||||||
value={calculateRemainingSeats()}
|
value={calculateRemainingSeats}
|
||||||
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
|
valueStyle={STATISTIC_VALUE_STYLE}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
@@ -319,17 +369,17 @@ const CurrentPlanDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
}, [billingInfo, shouldShowAddSeats, handleAddMoreSeats, calculateRemainingSeats, t]);
|
||||||
|
|
||||||
const renderCreditSubscriptionInfo = () => {
|
const renderCreditSubscriptionInfo = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
|
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
const renderCustomSubscriptionInfo = () => {
|
const renderCustomSubscriptionInfo = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
|
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
|
||||||
@@ -340,7 +390,36 @@ const CurrentPlanDetails = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -361,19 +440,10 @@ const CurrentPlanDetails = () => {
|
|||||||
>
|
>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
<div style={{ marginBottom: '14px' }}>
|
<div style={{ marginBottom: '14px' }}>
|
||||||
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
|
{renderSubscriptionContent()}
|
||||||
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()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowRedeemButton() && (
|
{shouldShowRedeemButton && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
@@ -408,17 +478,28 @@ const CurrentPlanDetails = () => {
|
|||||||
<Typography.Paragraph
|
<Typography.Paragraph
|
||||||
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
|
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>
|
||||||
|
|
||||||
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
|
||||||
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
|
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
|
||||||
seats: billingInfo?.total_seats,
|
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>
|
||||||
|
|
||||||
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
|
<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>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
@@ -430,18 +511,18 @@ const CurrentPlanDetails = () => {
|
|||||||
options={seatCountOptions}
|
options={seatCountOptions}
|
||||||
style={{ width: '300px' }}
|
style={{ width: '300px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Flex justify="end">
|
<Flex justify="end">
|
||||||
{selectedSeatCount.toString() !== '100+' ? (
|
{selectedSeatCount.toString() !== SEAT_COUNT_LIMIT ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
loading={addingSeats}
|
loading={addingSeats}
|
||||||
onClick={handlePurchaseMoreSeats}
|
onClick={handlePurchaseMoreSeats}
|
||||||
style={{
|
style={{
|
||||||
minWidth: '100px',
|
minWidth: '100px',
|
||||||
backgroundColor: '#1890ff',
|
...BUTTON_STYLE,
|
||||||
borderColor: '#1890ff',
|
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|||||||
@@ -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 React, { useState } from 'react';
|
||||||
import './upgrade-plans-lkr.css';
|
import './upgrade-plans-lkr.css';
|
||||||
import { CheckCircleFilled } from '@ant-design/icons';
|
import { CheckCircleFilled } from '@/shared/antd-imports';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd/es';
|
} from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { IPaddlePlans, SUBSCRIPTION_STATUS } from '@/shared/constants';
|
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 { useAuthService } from '@/hooks/useAuth';
|
||||||
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
|
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
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 { authApiService } from '@/api/auth/auth.api.service';
|
||||||
import { setUser } from '@/features/user/userSlice';
|
import { setUser } from '@/features/user/userSlice';
|
||||||
import { setSession } from '@/utils/session-helper';
|
import { setSession } from '@/utils/session-helper';
|
||||||
|
import './upgrade-plans.css';
|
||||||
|
|
||||||
// Extend Window interface to include Paddle
|
// Extend Window interface to include Paddle
|
||||||
declare global {
|
declare global {
|
||||||
@@ -65,20 +66,24 @@ const UpgradePlans = () => {
|
|||||||
const [paddleError, setPaddleError] = useState<string | null>(null);
|
const [paddleError, setPaddleError] = useState<string | null>(null);
|
||||||
|
|
||||||
const populateSeatCountOptions = (currentSeats: number) => {
|
const populateSeatCountOptions = (currentSeats: number) => {
|
||||||
if (!currentSeats) return [];
|
if (typeof currentSeats !== 'number') return [];
|
||||||
|
|
||||||
const step = 5;
|
const step = 5;
|
||||||
const maxSeats = 90;
|
const maxSeats = 90;
|
||||||
const minValue = Math.min(currentSeats + 1);
|
const minValue = currentSeats;
|
||||||
const rangeStart = Math.ceil(minValue / step) * step;
|
const options: { value: number; disabled: boolean }[] = [];
|
||||||
const range = Array.from(
|
|
||||||
{ length: Math.floor((maxSeats - rangeStart) / step) + 1 },
|
|
||||||
(_, i) => rangeStart + i * step
|
|
||||||
);
|
|
||||||
|
|
||||||
return currentSeats < step
|
// Always show 1-5, but disable if less than minValue
|
||||||
? [...Array.from({ length: rangeStart - minValue }, (_, i) => minValue + i), ...range]
|
for (let i = 1; i <= 5; i++) {
|
||||||
: range;
|
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 () => {
|
const fetchPricingPlans = async () => {
|
||||||
@@ -339,24 +344,25 @@ const UpgradePlans = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="upgrade-plans-responsive">
|
||||||
<Flex justify="center" align="center">
|
<Flex justify="center" align="center">
|
||||||
<Typography.Title level={2}>
|
<Typography.Title level={2}>
|
||||||
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
|
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
|
||||||
? t('selectPlan')
|
? t('selectPlan', 'Select Plan')
|
||||||
: t('changeSubscriptionPlan')}
|
: t('changeSubscriptionPlan', 'Change Subscription Plan')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex justify="center" align="center">
|
<Flex justify="center" align="center">
|
||||||
<Form form={form}>
|
<Form form={form}>
|
||||||
<Form.Item name="seatCount" label={t('noOfSeats')}>
|
<Form.Item name="seatCount" label={t('noOfSeats', 'Number of Seats')}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: 100 }}
|
style={{ width: 100 }}
|
||||||
value={selectedSeatCount}
|
value={selectedSeatCount}
|
||||||
options={seatCountOptions.map(option => ({
|
options={seatCountOptions.map(option => ({
|
||||||
value: option,
|
value: option.value,
|
||||||
text: option.toString(),
|
label: option.value.toString(),
|
||||||
|
disabled: option.disabled,
|
||||||
}))}
|
}))}
|
||||||
onChange={setSelectedSeatCount}
|
onChange={setSelectedSeatCount}
|
||||||
/>
|
/>
|
||||||
@@ -365,31 +371,31 @@ const UpgradePlans = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<Flex>
|
<Flex>
|
||||||
<Row className="w-full">
|
<Row className="w-full upgrade-plans-row-responsive">
|
||||||
{/* Free Plan */}
|
{/* Free Plan */}
|
||||||
<Col span={8} style={{ padding: '0 4px' }}>
|
<Col span={8} style={{ padding: '0 4px' }}>
|
||||||
<Card
|
<Card
|
||||||
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
|
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
|
||||||
hoverable
|
hoverable
|
||||||
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
|
title={<span style={cardStyles.title}>{t('freePlan', 'Free Plan')}</span>}
|
||||||
onClick={() => setSelectedCard(paddlePlans.FREE)}
|
onClick={() => setSelectedCard(paddlePlans.FREE)}
|
||||||
>
|
>
|
||||||
<div style={cardStyles.priceContainer}>
|
<div style={cardStyles.priceContainer}>
|
||||||
<Flex justify="space-between" align="center">
|
<Flex justify="space-between" align="center">
|
||||||
<Typography.Title level={1}>$ 0.00</Typography.Title>
|
<Typography.Title level={1}>$ 0.00</Typography.Title>
|
||||||
<Typography.Text>{t('freeForever')}</Typography.Text>
|
<Typography.Text>{t('freeForever', 'Free Forever')}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex justify="center" align="center">
|
<Flex justify="center" align="center">
|
||||||
<Typography.Text strong style={{ fontSize: '16px' }}>
|
<Typography.Text strong style={{ fontSize: '16px' }}>
|
||||||
{t('bestForPersonalUse')}
|
{t('bestForPersonalUse', 'Best for Personal Use')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={cardStyles.featureList}>
|
<div style={cardStyles.featureList}>
|
||||||
{renderFeature(`${plans.free_tier_storage} ${t('storage')}`)}
|
{renderFeature(`${plans.free_tier_storage} ${t('storage', 'Storage')}`)}
|
||||||
{renderFeature(`${plans.projects_limit} ${t('projects')}`)}
|
{renderFeature(`${plans.projects_limit} ${t('projects', 'Projects')}`)}
|
||||||
{renderFeature(`${plans.team_member_limit} ${t('teamMembers')}`)}
|
{renderFeature(`${plans.team_member_limit} ${t('teamMembers', 'Team Members')}`)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -401,9 +407,9 @@ const UpgradePlans = () => {
|
|||||||
hoverable
|
hoverable
|
||||||
title={
|
title={
|
||||||
<span style={cardStyles.title}>
|
<span style={cardStyles.title}>
|
||||||
{t('annualPlan')}{' '}
|
{t('annualPlan', 'Annual Plan')}{' '}
|
||||||
<Tag color="volcano" style={{ lineHeight: '21px' }}>
|
<Tag color="volcano" style={{ lineHeight: '21px' }}>
|
||||||
{t('tag')}
|
{t('tag', 'Popular')}
|
||||||
</Tag>
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -429,16 +435,16 @@ const UpgradePlans = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex justify="center" align="center">
|
<Flex justify="center" align="center">
|
||||||
<Typography.Text>{t('billedAnnually')}</Typography.Text>
|
<Typography.Text>{t('billedAnnually', 'Billed Annually')}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={cardStyles.featureList} className="mt-4">
|
<div style={cardStyles.featureList} className="mt-4">
|
||||||
{renderFeature(t('startupText01'))}
|
{renderFeature(t('startupText01', 'Unlimited Projects'))}
|
||||||
{renderFeature(t('startupText02'))}
|
{renderFeature(t('startupText02', 'Unlimited Team Members'))}
|
||||||
{renderFeature(t('startupText03'))}
|
{renderFeature(t('startupText03', 'Unlimited Storage'))}
|
||||||
{renderFeature(t('startupText04'))}
|
{renderFeature(t('startupText04', 'Priority Support'))}
|
||||||
{renderFeature(t('startupText05'))}
|
{renderFeature(t('startupText05', 'Advanced Analytics'))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -448,7 +454,7 @@ const UpgradePlans = () => {
|
|||||||
<Card
|
<Card
|
||||||
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
|
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
|
||||||
hoverable
|
hoverable
|
||||||
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
|
title={<span style={cardStyles.title}>{t('monthlyPlan', 'Monthly Plan')}</span>}
|
||||||
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
|
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
|
||||||
>
|
>
|
||||||
<div style={cardStyles.priceContainer}>
|
<div style={cardStyles.priceContainer}>
|
||||||
@@ -469,16 +475,16 @@ const UpgradePlans = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex justify="center" align="center">
|
<Flex justify="center" align="center">
|
||||||
<Typography.Text>{t('billedMonthly')}</Typography.Text>
|
<Typography.Text>{t('billedMonthly', 'Billed Monthly')}</Typography.Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={cardStyles.featureList}>
|
<div style={cardStyles.featureList}>
|
||||||
{renderFeature(t('startupText01'))}
|
{renderFeature(t('startupText01', 'Unlimited Projects'))}
|
||||||
{renderFeature(t('startupText02'))}
|
{renderFeature(t('startupText02', 'Unlimited Team Members'))}
|
||||||
{renderFeature(t('startupText03'))}
|
{renderFeature(t('startupText03', 'Unlimited Storage'))}
|
||||||
{renderFeature(t('startupText04'))}
|
{renderFeature(t('startupText04', 'Priority Support'))}
|
||||||
{renderFeature(t('startupText05'))}
|
{renderFeature(t('startupText05', 'Advanced Analytics'))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
@@ -509,8 +515,8 @@ const UpgradePlans = () => {
|
|||||||
disabled={billingInfo?.plan_id === plans.annual_plan_id}
|
disabled={billingInfo?.plan_id === plans.annual_plan_id}
|
||||||
>
|
>
|
||||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
? t('changeToPlan', { plan: t('annualPlan') })
|
? t('changeToPlan', 'Change to {{plan}}', { plan: t('annualPlan', 'Annual Plan') })
|
||||||
: t('continueWith', { plan: t('annualPlan') })}
|
: t('continueWith', 'Continue with {{plan}}', { plan: t('annualPlan', 'Annual Plan') })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedPlan === paddlePlans.MONTHLY && (
|
{selectedPlan === paddlePlans.MONTHLY && (
|
||||||
@@ -522,8 +528,8 @@ const UpgradePlans = () => {
|
|||||||
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
|
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
|
||||||
>
|
>
|
||||||
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
|
||||||
? t('changeToPlan', { plan: t('monthlyPlan') })
|
? t('changeToPlan', 'Change to {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })
|
||||||
: t('continueWith', { plan: t('monthlyPlan') })}
|
: t('continueWith', 'Continue with {{plan}}', { plan: t('monthlyPlan', 'Monthly Plan') })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd';
|
import { Button, Card, Col, Divider, Form, Input, Row, Select } from '@/shared/antd-imports';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { RootState } from '../../../app/store';
|
import { RootState } from '../../../app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { IBillingConfigurationCountry } from '@/types/admin-center/country.types';
|
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 { IBillingConfiguration } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
const Configuration: React.FC = () => {
|
const Configuration: React.FC = React.memo(() => {
|
||||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||||
|
|
||||||
const [countries, setCountries] = useState<IBillingConfigurationCountry[]>([]);
|
const [countries, setCountries] = useState<IBillingConfigurationCountry[]>([]);
|
||||||
const [configuration, setConfiguration] = useState<IBillingConfiguration>();
|
const [configuration, setConfiguration] = useState<IBillingConfiguration>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const fetchCountries = async () => {
|
|
||||||
|
const fetchCountries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await adminCenterApiService.getCountries();
|
const res = await adminCenterApiService.getCountries();
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
@@ -23,61 +24,85 @@ const Configuration: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching countries:', error);
|
logger.error('Error fetching countries:', error);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const fetchConfiguration = async () => {
|
const fetchConfiguration = useCallback(async () => {
|
||||||
const res = await adminCenterApiService.getBillingConfiguration();
|
const res = await adminCenterApiService.getBillingConfiguration();
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
setConfiguration(res.body);
|
setConfiguration(res.body);
|
||||||
form.setFieldsValue(res.body);
|
form.setFieldsValue(res.body);
|
||||||
}
|
}
|
||||||
};
|
}, [form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCountries();
|
fetchCountries();
|
||||||
fetchConfiguration();
|
fetchConfiguration();
|
||||||
}, []);
|
}, [fetchCountries, fetchConfiguration]);
|
||||||
|
|
||||||
const handleSave = async (values: any) => {
|
const handleSave = useCallback(
|
||||||
try {
|
async (values: any) => {
|
||||||
setLoading(true);
|
try {
|
||||||
const res = await adminCenterApiService.updateBillingConfiguration(values);
|
setLoading(true);
|
||||||
if (res.done) {
|
const res = await adminCenterApiService.updateBillingConfiguration(values);
|
||||||
fetchConfiguration();
|
if (res.done) {
|
||||||
|
fetchConfiguration();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating configuration:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
},
|
||||||
logger.error('Error updating configuration:', error);
|
[fetchConfiguration]
|
||||||
} finally {
|
);
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const countryOptions = countries.map(country => ({
|
const countryOptions = useMemo(
|
||||||
label: country.name,
|
() =>
|
||||||
value: country.id,
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card title={<span style={titleStyle}>Billing Details</span>} style={cardStyle}>
|
||||||
title={
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: '16px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Billing Details
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
style={{ marginTop: '16px' }}
|
|
||||||
>
|
|
||||||
<Form form={form} initialValues={configuration} onFinish={handleSave}>
|
<Form form={form} initialValues={configuration} onFinish={handleSave}>
|
||||||
<Row>
|
<Row gutter={[0, 0]}>
|
||||||
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
|
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="Name"
|
label="Name"
|
||||||
@@ -88,10 +113,10 @@ const Configuration: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="Name" />
|
<Input placeholder="Name" disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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
|
<Form.Item
|
||||||
name="email"
|
name="email"
|
||||||
label="Email Address"
|
label="Email Address"
|
||||||
@@ -102,10 +127,10 @@ const Configuration: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input placeholder="Name" disabled />
|
<Input placeholder="Email Address" disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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
|
<Form.Item
|
||||||
name="phone"
|
name="phone"
|
||||||
label="Contact Number"
|
label="Contact Number"
|
||||||
@@ -117,58 +142,34 @@ const Configuration: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input placeholder="Phone Number" maxLength={10} onInput={handlePhoneInput} />
|
||||||
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
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Divider orientation="left" style={{ margin: '16px 0' }}>
|
<Divider orientation="left" style={{ ...dividerStyle, fontSize: '14px' }}>
|
||||||
<span
|
<span style={dividerTitleStyle}>Company Details</span>
|
||||||
style={{
|
|
||||||
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '16px',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Company Details
|
|
||||||
</span>
|
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Row>
|
<Row gutter={[0, 0]}>
|
||||||
<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="company_name" label="Company Name" layout="vertical">
|
<Form.Item name="company_name" label="Company Name" layout="vertical">
|
||||||
<Input placeholder="Company Name" />
|
<Input placeholder="Company Name" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="address_line_1" label="Address Line 01" layout="vertical">
|
||||||
<Input placeholder="Address Line 01" />
|
<Input placeholder="Address Line 01" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="address_line_2" label="Address Line 02" layout="vertical">
|
||||||
<Input placeholder="Address Line 02" />
|
<Input placeholder="Address Line 02" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row gutter={[0, 0]}>
|
||||||
<Col
|
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={colStyle}>
|
||||||
span={8}
|
|
||||||
style={{
|
|
||||||
padding: '0 12px',
|
|
||||||
height: '86px',
|
|
||||||
scrollbarColor: 'red',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Form.Item name="country" label="Country" layout="vertical">
|
<Form.Item name="country" label="Country" layout="vertical">
|
||||||
<Select
|
<Select
|
||||||
dropdownStyle={{ maxHeight: 256, overflow: 'auto' }}
|
dropdownStyle={{ maxHeight: 256, overflow: 'auto' }}
|
||||||
@@ -181,28 +182,28 @@ const Configuration: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="city" label="City" layout="vertical">
|
||||||
<Input placeholder="City" />
|
<Input placeholder="City" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="state" label="State" layout="vertical">
|
||||||
<Input placeholder="State" />
|
<Input placeholder="State" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row gutter={[0, 0]}>
|
||||||
<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="postal_code" label="Postal Code" layout="vertical">
|
<Form.Item name="postal_code" label="Postal Code" layout="vertical">
|
||||||
<Input placeholder="Postal Code" />
|
<Input placeholder="Postal Code" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
<Col style={{ paddingLeft: '12px' }}>
|
<Col xs={24} sm={24} md={8} lg={8} xl={8} style={{ ...buttonColStyle, marginTop: 8 }}>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -212,6 +213,8 @@ const Configuration: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
Configuration.displayName = 'Configuration';
|
||||||
|
|
||||||
export default Configuration;
|
export default Configuration;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Table, TableProps, Typography } from 'antd';
|
import { Table, TableProps, Typography } from '@/shared/antd-imports';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
import { IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { EnterOutlined, EditOutlined } from '@ant-design/icons';
|
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||||
import { Card, Button, Tooltip, Typography } from 'antd';
|
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
|
||||||
import TextArea from 'antd/es/input/TextArea';
|
import TextArea from 'antd/es/input/TextArea';
|
||||||
import Paragraph from 'antd/es/typography/Paragraph';
|
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||||
import { IOrganization } from '@/types/admin-center/admin-center.types';
|
import { IOrganization } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { MailOutlined, PhoneOutlined, EditOutlined } from '@ant-design/icons';
|
import { MailOutlined, PhoneOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||||
import { Card, Tooltip, Input, Button, Typography, InputRef } from 'antd';
|
import { Card, Tooltip, Input, Button, Typography, InputRef } from '@/shared/antd-imports';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
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 { fetchTeams } from '@features/teams/teamSlice';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
TableProps,
|
TableProps,
|
||||||
Typography,
|
Typography,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antd';
|
} from '@/shared/antd-imports';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import './settings-drawer.css';
|
import './settings-drawer.css';
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { toggleSettingDrawer, deleteTeam, fetchTeams } from '@/features/teams/te
|
|||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { SettingOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { SettingOutlined, DeleteOutlined } from '@/shared/antd-imports';
|
||||||
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
|
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from '@/shared/antd-imports';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMediaQuery } from 'react-responsive';
|
import { useMediaQuery } from 'react-responsive';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user