Compare commits
4 Commits
feature/gu
...
fix/timelo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1931856d31 | ||
|
|
daa65465dd | ||
|
|
de26417247 | ||
|
|
69b2fe1a90 |
@@ -4,7 +4,9 @@
|
||||
"Bash(find:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run type-check:*)",
|
||||
"Bash(npm run:*)"
|
||||
"Bash(npm run:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(rm:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# General Coding Guidelines
|
||||
|
||||
## Rule Summary
|
||||
Follow these rules when you write code:
|
||||
|
||||
1. **Use Early Returns**
|
||||
- Prefer early returns and guard clauses to reduce nesting and improve readability, especially for error handling.
|
||||
|
||||
2. **Tailwind for Styling**
|
||||
- Always use Tailwind CSS utility classes for styling HTML elements.
|
||||
- Avoid writing custom CSS or using inline `style` tags.
|
||||
|
||||
3. **Class Tag Syntax**
|
||||
- Use `class:` directive (e.g., `class:active={isActive}`) instead of the ternary operator in class tags whenever possible.
|
||||
|
||||
4. **Descriptive Naming**
|
||||
- Use clear, descriptive names for variables, functions, and constants.
|
||||
- Use auxiliary verbs for booleans and state (e.g., `isLoaded`, `hasError`, `shouldRender`).
|
||||
- Event handler functions should be prefixed with `handle`, e.g., `handleClick` for `onClick`, `handleKeyDown` for `onKeyDown`.
|
||||
|
||||
5. **Naming Conventions**
|
||||
- **Directories:** Use lowercase with dashes (e.g., `components/auth-wizard`).
|
||||
- **Variables & Functions:** Use `camelCase` (e.g., `userList`, `fetchData`).
|
||||
- **Types & Interfaces:** Use `PascalCase` (e.g., `User`, `ButtonProps`).
|
||||
- **Exports:** Favor named exports for components.
|
||||
- **No Unused Variables:** Remove unused variables and imports.
|
||||
|
||||
6. **File Layout**
|
||||
- Order: exported component → subcomponents → hooks/helpers → static content.
|
||||
|
||||
7. **Props & Types**
|
||||
- Define props with TypeScript `interface` or `type`, not `prop-types`.
|
||||
- Example:
|
||||
```ts
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ label, onClick }: ButtonProps) {
|
||||
return <button onClick={onClick}>{label}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
8. **Component Declaration**
|
||||
- Use the `function` keyword for components, not arrow functions.
|
||||
|
||||
9. **Hooks Usage**
|
||||
- Call hooks (e.g., `useState`, `useEffect`) only at the top level of components.
|
||||
- Extract reusable logic into custom hooks (e.g., `useAuth`, `useFormValidation`).
|
||||
|
||||
10. **Memoization & Performance**
|
||||
- Use `React.memo`, `useCallback`, and `useMemo` where appropriate.
|
||||
- Avoid inline functions in JSX—pull handlers out or wrap in `useCallback`.
|
||||
|
||||
11. **Composition**
|
||||
- Favor composition (render props, `children`) over inheritance.
|
||||
|
||||
12. **Code Splitting**
|
||||
- Use `React.lazy` + `Suspense` for code splitting.
|
||||
|
||||
13. **Refs**
|
||||
- Use refs only for direct DOM access.
|
||||
|
||||
14. **Forms**
|
||||
- Prefer controlled components for forms.
|
||||
|
||||
15. **Error Boundaries**
|
||||
- Implement an error boundary component for catching render errors.
|
||||
|
||||
16. **Effect Cleanup**
|
||||
- Clean up effects in `useEffect` to prevent memory leaks.
|
||||
|
||||
17. **Accessibility**
|
||||
- Apply appropriate ARIA attributes to interactive elements.
|
||||
- For example, an `<a>` tag should have `tabindex="0"`, `aria-label`, `onClick`, and `onKeyDown` attributes as appropriate.
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ Correct
|
||||
```tsx
|
||||
// File: components/user-profile.tsx
|
||||
|
||||
interface UserProfileProps {
|
||||
user: User;
|
||||
isLoaded: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export function UserProfile({ user, isLoaded, hasError }: UserProfileProps) {
|
||||
if (!isLoaded) return <div>Loading...</div>;
|
||||
if (hasError) return <div role="alert">Error loading user.</div>;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// ...
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="bg-blue-500 text-white"
|
||||
aria-label="View user profile"
|
||||
tabIndex={0}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{user.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Incorrect
|
||||
```tsx
|
||||
// File: components/UserProfile.jsx
|
||||
function userprofile(props) {
|
||||
if (props.isLoaded) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button style={{ color: 'white' }} onClick={() => doSomething()}>
|
||||
View
|
||||
</button>
|
||||
);
|
||||
```
|
||||
|
||||
## Enforcement
|
||||
- All new code must follow these guidelines.
|
||||
- Code reviews should reject code that does not comply with these rules.
|
||||
- Refactor existing code to follow these guidelines when making changes.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Localization Rule: No Hard-Coded User-Facing Text
|
||||
|
||||
## Rule
|
||||
- All user-facing text **must** be added to the localization system at `@/locales`.
|
||||
- **Never** hard-code user-facing strings directly in components, pages, or business logic.
|
||||
- Use the appropriate i18n or localization utility to fetch and display all text.
|
||||
- **Always** provide a `defaultValue` when using the `t()` function for translations, e.g., `{t('emailPlaceholder', {defaultValue: 'Enter your email'})}`.
|
||||
|
||||
## Rationale
|
||||
- Ensures the application is fully translatable and accessible to all supported languages.
|
||||
- Prevents missed strings during translation updates.
|
||||
- Promotes consistency and maintainability.
|
||||
- Providing a `defaultValue` ensures a fallback is shown if the translation key is missing.
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ Correct
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return <input placeholder={t('emailPlaceholder', { defaultValue: 'Enter your email' })} />;
|
||||
```
|
||||
|
||||
### ❌ Incorrect
|
||||
```tsx
|
||||
return <input placeholder={t('emailPlaceholder')} />;
|
||||
|
||||
// or
|
||||
return <input placeholder="Enter your email" />;
|
||||
```
|
||||
|
||||
## Enforcement
|
||||
- All new user-facing text **must** be added to the appropriate file in `@/locales`.
|
||||
- Every use of `t()` **must** include a `defaultValue` for fallback.
|
||||
- Code reviews should reject any hard-coded user-facing strings or missing `defaultValue` in translations.
|
||||
- Refactor existing hard-coded text to use the localization system and add `defaultValue` when modifying related code.
|
||||
@@ -1,39 +0,0 @@
|
||||
# React Component Naming Rule: PascalCase
|
||||
|
||||
## Rule
|
||||
- All React component names **must** use PascalCase.
|
||||
- This applies to:
|
||||
- Component file names (e.g., `MyComponent.tsx`, `UserProfile.jsx`)
|
||||
- Exported component identifiers (e.g., `export const MyComponent = ...` or `function UserProfile() { ... }`)
|
||||
|
||||
## Rationale
|
||||
- PascalCase is the community standard for React components.
|
||||
- Ensures consistency and readability across the codebase.
|
||||
- Prevents confusion between components and regular functions/variables.
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ Correct
|
||||
```tsx
|
||||
// File: UserProfile.tsx
|
||||
export function UserProfile() { ... }
|
||||
|
||||
// File: TaskList.tsx
|
||||
const TaskList = () => { ... }
|
||||
export default TaskList;
|
||||
```
|
||||
|
||||
### ❌ Incorrect
|
||||
```tsx
|
||||
// File: userprofile.tsx
|
||||
export function userprofile() { ... }
|
||||
|
||||
// File: task-list.jsx
|
||||
const task_list = () => { ... }
|
||||
export default task_list;
|
||||
```
|
||||
|
||||
## Enforcement
|
||||
- All new React components **must** follow this rule.
|
||||
- Refactor existing components to PascalCase when modifying or moving them.
|
||||
- Code reviews should reject non-PascalCase component names.
|
||||
@@ -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 { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||
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";
|
||||
|
||||
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(
|
||||
teamId: string, searchQuery = "",
|
||||
@@ -487,7 +546,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
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 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> {
|
||||
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 billableQuery = this.buildBillableQuery(billable);
|
||||
@@ -1230,8 +1293,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
||||
row.actual_time = int(row.actual_time);
|
||||
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
||||
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
||||
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
|
||||
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
|
||||
row.days_left = this.getDaysLeft(row.end_date);
|
||||
row.is_overdue = this.isOverdue(row.end_date);
|
||||
if (row.days_left && row.is_overdue) {
|
||||
row.days_left = row.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
|
||||
@@ -38,16 +38,10 @@
|
||||
"updateMemberErrorMessage": "Aktualisierung des Teammitglieds fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"memberText": "Mitglied",
|
||||
"adminText": "Administrator",
|
||||
"guestText": "Gast (Nur Lesen)",
|
||||
"ownerText": "Team-Besitzer",
|
||||
"addedText": "Hinzugefügt",
|
||||
"updatedText": "Aktualisiert",
|
||||
"noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...",
|
||||
"jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel",
|
||||
"invitationResent": "Einladung erfolgreich erneut gesendet!",
|
||||
"emailsStepDescription": "Geben Sie E-Mail-Adressen für Teammitglieder ein, die Sie einladen möchten",
|
||||
"personalMessageLabel": "Persönliche Nachricht",
|
||||
"personalMessagePlaceholder": "Fügen Sie eine persönliche Nachricht zu Ihrer Einladung hinzu (optional)",
|
||||
"optionalFieldLabel": "(Optional)",
|
||||
"inviteTeamMembersModalTitle": "Teammitglieder einladen"
|
||||
"invitationResent": "Einladung erfolgreich erneut gesendet!"
|
||||
}
|
||||
|
||||
@@ -38,16 +38,10 @@
|
||||
"updateMemberErrorMessage": "Failed to update team member. Please try again.",
|
||||
"memberText": "Member",
|
||||
"adminText": "Admin",
|
||||
"guestText": "Guest (Read-only)",
|
||||
"ownerText": "Team Owner",
|
||||
"addedText": "Added",
|
||||
"updatedText": "Updated",
|
||||
"noResultFound": "Type an email address and hit enter...",
|
||||
"jobTitlesFetchError": "Failed to fetch job titles",
|
||||
"invitationResent": "Invitation resent successfully!",
|
||||
"emailsStepDescription": "Enter email addresses for team members you'd like to invite",
|
||||
"personalMessageLabel": "Personal Message",
|
||||
"personalMessagePlaceholder": "Add a personal message to your invitation (optional)",
|
||||
"optionalFieldLabel": "(Optional)",
|
||||
"inviteTeamMembersModalTitle": "Invite team members"
|
||||
"invitationResent": "Invitation resent successfully!"
|
||||
}
|
||||
|
||||
@@ -38,16 +38,10 @@
|
||||
"updateMemberErrorMessage": "Error al actualizar miembro del equipo. Por favor, intente nuevamente.",
|
||||
"memberText": "Miembro del equipo",
|
||||
"adminText": "Administrador",
|
||||
"guestText": "Invitado (Solo lectura)",
|
||||
"ownerText": "Propietario del equipo",
|
||||
"addedText": "Agregado",
|
||||
"updatedText": "Actualizado",
|
||||
"noResultFound": "Escriba una dirección de correo electrónico y presione enter...",
|
||||
"jobTitlesFetchError": "Error al obtener los cargos",
|
||||
"invitationResent": "¡Invitación reenviada exitosamente!",
|
||||
"emailsStepDescription": "Ingrese las direcciones de correo de los miembros del equipo que desea invitar",
|
||||
"personalMessageLabel": "Mensaje Personal",
|
||||
"personalMessagePlaceholder": "Agregue un mensaje personal a su invitación (opcional)",
|
||||
"optionalFieldLabel": "(Opcional)",
|
||||
"inviteTeamMembersModalTitle": "Invitar miembros del equipo"
|
||||
"invitationResent": "¡Invitación reenviada exitosamente!"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
import Avatar from 'antd/es/avatar';
|
||||
|
||||
import { AvatarNamesMap } from '../shared/constants';
|
||||
import { Avatar, Tooltip } from '@/shared/antd-imports';
|
||||
|
||||
interface CustomAvatarProps {
|
||||
avatarName: string;
|
||||
|
||||
50
worklenz-frontend/src/components/TawkTo.tsx
Normal file
50
worklenz-frontend/src/components/TawkTo.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Add TypeScript declarations for Tawk_API
|
||||
declare global {
|
||||
interface Window {
|
||||
Tawk_API?: any;
|
||||
Tawk_LoadStart?: Date;
|
||||
}
|
||||
}
|
||||
|
||||
interface TawkToProps {
|
||||
propertyId: string;
|
||||
widgetId: string;
|
||||
}
|
||||
|
||||
const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
|
||||
useEffect(() => {
|
||||
// Initialize tawk.to chat
|
||||
const s1 = document.createElement('script');
|
||||
s1.async = true;
|
||||
s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`;
|
||||
s1.setAttribute('crossorigin', '*');
|
||||
|
||||
const s0 = document.getElementsByTagName('script')[0];
|
||||
s0.parentNode?.insertBefore(s1, s0);
|
||||
|
||||
return () => {
|
||||
// Clean up when the component unmounts
|
||||
// Remove the script tag
|
||||
const tawkScript = document.querySelector(`script[src*="tawk.to/${propertyId}"]`);
|
||||
if (tawkScript && tawkScript.parentNode) {
|
||||
tawkScript.parentNode.removeChild(tawkScript);
|
||||
}
|
||||
|
||||
// Remove the tawk.to iframe
|
||||
const tawkIframe = document.getElementById('tawk-iframe');
|
||||
if (tawkIframe) {
|
||||
tawkIframe.remove();
|
||||
}
|
||||
|
||||
// Reset Tawk globals
|
||||
delete window.Tawk_API;
|
||||
delete window.Tawk_LoadStart;
|
||||
};
|
||||
}, [propertyId, widgetId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TawkTo;
|
||||
@@ -1,8 +1,11 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { fetchStorageInfo } from '@/features/admin-center/admin-center.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { SUBSCRIPTION_STATUS } from '@/shared/constants';
|
||||
import { Card, Progress, Typography } from '@/shared/antd-imports';
|
||||
import { IBillingAccountStorage } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { Card, Progress, Typography } from 'antd/es';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { EnterOutlined, EditOutlined } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography, TextArea } from '@/shared/antd-imports';
|
||||
import { Card, Button, Tooltip, Typography } from '@/shared/antd-imports';
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import Card from 'antd/es/card';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Divider from 'antd/es/divider';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Input from 'antd/es/input';
|
||||
import List from 'antd/es/list';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
InputRef,
|
||||
PlusOutlined,
|
||||
UsergroupAddOutlined,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
List,
|
||||
Typography,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Button,
|
||||
Empty,
|
||||
Dropdown,
|
||||
CheckboxChangeEvent,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '@features/projects/singleProject/members/projectMembersSlice';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { PlusOutlined, UsergroupAddOutlined } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { sortByBooleanField, sortBySelection, sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
|
||||
interface BoardAssigneeSelectorProps {
|
||||
task: IProjectTask;
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { Button, Flex, Input, message, Modal, Select, Space, Typography, List, Avatar } from '@/shared/antd-imports';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
toggleInviteMemberDrawer,
|
||||
triggerTeamMembersRefresh,
|
||||
} from '../../../features/settings/member/memberSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useEffect, useCallback, useMemo, memo } from 'react';
|
||||
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
|
||||
import { DeleteOutlined, UserOutlined } from '@ant-design/icons';
|
||||
|
||||
interface MemberEntry {
|
||||
email: string;
|
||||
access: 'member' | 'admin' | 'guest';
|
||||
}
|
||||
|
||||
|
||||
const InviteTeamMembersModal = () => {
|
||||
const [newMembers, setNewMembers] = useState<MemberEntry[]>([]);
|
||||
const [emailInput, setEmailInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { t } = useTranslation('settings/team-members');
|
||||
const isModalOpen = useAppSelector(state => state.memberReducer.isInviteMemberDrawerOpen);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
// Reset state when modal opens
|
||||
setNewMembers([]);
|
||||
setEmailInput('');
|
||||
// Focus on email input when modal opens
|
||||
setTimeout(() => {
|
||||
const emailInput = document.querySelector('input[type="text"]');
|
||||
if (emailInput) {
|
||||
(emailInput as HTMLElement).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [isModalOpen]);
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (newMembers.length === 0) {
|
||||
message.error('Please add at least one member');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send invitations for each member
|
||||
const promises = newMembers.map(member => {
|
||||
const body: ITeamMemberCreateRequest = {
|
||||
emails: [member.email],
|
||||
is_admin: member.access === 'admin',
|
||||
is_guest: member.access === 'guest',
|
||||
};
|
||||
return teamMembersApiService.createTeamMember(body);
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
|
||||
if (successful > 0) {
|
||||
message.success(`${successful} invitation(s) sent successfully`);
|
||||
setNewMembers([]);
|
||||
setEmailInput('');
|
||||
dispatch(triggerTeamMembersRefresh());
|
||||
dispatch(toggleInviteMemberDrawer());
|
||||
}
|
||||
|
||||
const failed = results.length - successful;
|
||||
if (failed > 0) {
|
||||
message.error(`${failed} invitation(s) failed`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('createMemberErrorMessage'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setNewMembers([]);
|
||||
setEmailInput('');
|
||||
dispatch(toggleInviteMemberDrawer());
|
||||
};
|
||||
|
||||
const handleEmailKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmedEmail = emailInput.trim();
|
||||
|
||||
// Don't show error for empty input, just ignore
|
||||
if (!trimmedEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
if (!emailPattern.test(trimmedEmail)) {
|
||||
message.error('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
if (newMembers.find(m => m.email === trimmedEmail)) {
|
||||
message.warning('Email already added');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add new member
|
||||
setNewMembers([...newMembers, { email: trimmedEmail, access: 'member' }]);
|
||||
setEmailInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberAccess = (index: number, access: 'member' | 'admin' | 'guest') => {
|
||||
const updated = [...newMembers];
|
||||
updated[index].access = access;
|
||||
setNewMembers(updated);
|
||||
};
|
||||
|
||||
const removeMember = (index: number) => {
|
||||
setNewMembers(newMembers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const accessOptions = useMemo(() => [
|
||||
{ value: 'member', label: t('memberText') },
|
||||
{ value: 'admin', label: t('adminText') },
|
||||
{ value: 'guest', label: t('guestText') },
|
||||
], [t]);
|
||||
|
||||
const renderContent = () => (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="Enter email address and press Enter to add"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
onKeyPress={handleEmailKeyPress}
|
||||
size="middle"
|
||||
autoFocus
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
fontSize: 14
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginTop: 6, display: 'block', fontStyle: 'italic' }}>
|
||||
Press Enter to add • Multiple emails can be added
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{newMembers.length > 0 && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13, marginBottom: 12, display: 'block', fontWeight: 500 }}>
|
||||
Members to invite ({newMembers.length})
|
||||
</Typography.Text>
|
||||
<div style={{
|
||||
background: 'rgba(0, 0, 0, 0.02)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)'
|
||||
}}>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={newMembers}
|
||||
split={false}
|
||||
renderItem={(member, index) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
borderRadius: 6,
|
||||
marginBottom: index < newMembers.length - 1 ? 4 : 0
|
||||
}}
|
||||
actions={[
|
||||
<Select
|
||||
size="small"
|
||||
value={member.access}
|
||||
onChange={(value) => updateMemberAccess(index, value)}
|
||||
options={accessOptions}
|
||||
style={{ width: 90 }}
|
||||
variant="outlined"
|
||||
/>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeMember(index)}
|
||||
size="small"
|
||||
danger
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar size={32} style={{
|
||||
backgroundColor: '#1677ff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<UserOutlined style={{ fontSize: 14 }} />
|
||||
</Avatar>
|
||||
}
|
||||
title={
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(0, 0, 0, 0.88)'
|
||||
}}>
|
||||
{member.email}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
color: '#52c41a',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
Ready to invite
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Typography.Text strong style={{ fontSize: 18 }}>
|
||||
{t('inviteTeamMembersModalTitle')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isModalOpen}
|
||||
onCancel={handleClose}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
footer={
|
||||
<Flex justify="end">
|
||||
<Space>
|
||||
<Button onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleFormSubmit}
|
||||
loading={loading}
|
||||
disabled={newMembers.length === 0}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(InviteTeamMembersModal);
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './InviteTeamMembersModal';
|
||||
@@ -1,32 +1,26 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Empty } from '@/shared/antd-imports';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import {
|
||||
reorderGroups,
|
||||
reorderEnhancedKanbanGroups,
|
||||
reorderTasks,
|
||||
reorderEnhancedKanbanTasks,
|
||||
fetchEnhancedKanbanLabels,
|
||||
fetchEnhancedKanbanGroups,
|
||||
fetchEnhancedKanbanTaskAssignees,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import KanbanGroup from './KanbanGroup';
|
||||
import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection';
|
||||
import ImprovedTaskFilters from '../../task-management/improved-task-filters';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import '../EnhancedKanbanBoard.css';
|
||||
import '../EnhancedKanbanGroup.css';
|
||||
import '../EnhancedKanbanTaskCard.css';
|
||||
import ImprovedTaskFilters from '../../task-management/improved-task-filters';
|
||||
import Card from 'antd/es/card';
|
||||
import Spin from 'antd/es/spin';
|
||||
import Empty from 'antd/es/empty';
|
||||
import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import KanbanGroup from './KanbanGroup';
|
||||
import EnhancedKanbanCreateSection from '../EnhancedKanbanCreateSection';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
|
||||
const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
ForkOutlined,
|
||||
CaretDownFilled,
|
||||
CaretRightFilled,
|
||||
Tag,
|
||||
Flex,
|
||||
Tooltip,
|
||||
Progress,
|
||||
Typography,
|
||||
Button,
|
||||
Divider,
|
||||
List,
|
||||
Skeleton,
|
||||
PlusOutlined,
|
||||
Dayjs,
|
||||
dayjs,
|
||||
} from '@/shared/antd-imports';
|
||||
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import './EnhancedKanbanTaskCard.css';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Tag from 'antd/es/tag';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
import Progress from 'antd/es/progress';
|
||||
import Button from 'antd/es/button';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
|
||||
import PrioritySection from '../board/taskCard/priority-section/priority-section';
|
||||
import Typography from 'antd/es/typography';
|
||||
import CustomDueDatePicker from '../board/custom-due-date-picker';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { ForkOutlined } from '@/shared/antd-imports';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { CaretDownFilled, CaretRightFilled } from '@/shared/antd-imports';
|
||||
import {
|
||||
fetchBoardSubTasks,
|
||||
toggleTaskExpansion,
|
||||
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import './EnhancedKanbanTaskCard.css';
|
||||
import LazyAssigneeSelectorWrapper from '../task-management/lazy-assignee-selector';
|
||||
import CustomDueDatePicker from '../board/custom-due-date-picker';
|
||||
import { Divider } from '@/shared/antd-imports';
|
||||
import { List } from '@/shared/antd-imports';
|
||||
import { Skeleton } from '@/shared/antd-imports';
|
||||
import { PlusOutlined } from '@/shared/antd-imports';
|
||||
import BoardSubTaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-sub-task-card';
|
||||
import BoardCreateSubtaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-create-sub-task-card';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import EnhancedKanbanCreateSubtaskCard from './EnhancedKanbanCreateSubtaskCard';
|
||||
import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector';
|
||||
import AvatarGroup from '@/components/AvatarGroup';
|
||||
|
||||
interface EnhancedKanbanTaskCardProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SettingOutlined, Tooltip, Button } from '@/shared/antd-imports';
|
||||
import { SettingOutlined } from '@/shared/antd-imports';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
import Button from 'antd/es/button';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleDrawer } from '../../../features/projects/status/StatusSlice';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Form, Input, Select, Button, Drawer, Flex, Badge } from '@/shared/antd-imports';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Drawer from 'antd/es/drawer';
|
||||
import Form from 'antd/es/form';
|
||||
import Input from 'antd/es/input';
|
||||
import Select from 'antd/es/select';
|
||||
import Button from 'antd/es/button/button';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
|
||||
import {
|
||||
createStatus,
|
||||
fetchStatusesCategories,
|
||||
fetchStatuses,
|
||||
} from '@/features/taskAttributes/taskStatusSlice';
|
||||
import './create-status-drawer.css';
|
||||
|
||||
import { createStatus, fetchStatusesCategories, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ITaskStatusCategory } from '@/types/status.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
@@ -18,8 +22,6 @@ import { evt_project_board_create_status } from '@/shared/worklenz-analytics-eve
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
|
||||
|
||||
import './create-status-drawer.css';
|
||||
|
||||
const StatusDrawer: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Drawer, Alert, Card, Select, Button, Typography, Badge, Form } from '@/shared/antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Form from 'antd/es/form';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
@@ -9,7 +9,9 @@ import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
|
||||
import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatusSlice';
|
||||
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from '@/shared/antd-imports';
|
||||
import { DownOutlined } from '@/shared/antd-imports';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
@@ -19,6 +21,7 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
const DeleteStatusDrawer: React.FC = () => {
|
||||
const [currentStatus, setCurrentStatus] = useState<string>('');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CaretDownFilled, Dropdown, Button, Flex, ConfigProvider } from '@/shared/antd-imports';
|
||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
||||
import { ConfigProvider, Flex, Dropdown, Button } from 'antd/es';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import ConfigPhaseButton from '@features/projects/singleProject/phase/ConfigPhaseButton';
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
CaretDownFilled,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
List,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Button,
|
||||
Empty,
|
||||
Space,
|
||||
InputRef,
|
||||
} from '@/shared/antd-imports';
|
||||
import { CaretDownFilled } from '@/shared/antd-imports';
|
||||
import Badge from 'antd/es/badge';
|
||||
import Button from 'antd/es/button';
|
||||
import Card from 'antd/es/card';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Input, { InputRef } from 'antd/es/input';
|
||||
import List from 'antd/es/list';
|
||||
import Space from 'antd/es/space';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Col, ConfigProvider, Flex, Menu, MenuProps, Alert } from '@/shared/antd-imports';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import InviteTeamMembersModal from '../../components/common/invite-team-members-modal';
|
||||
import InviteTeamMembers from '../../components/common/invite-team-members/invite-team-members';
|
||||
import InviteButton from './invite/InviteButton';
|
||||
import MobileMenuButton from './mobileMenu/MobileMenuButton';
|
||||
import NavbarLogo from './navbar-logo';
|
||||
@@ -179,7 +179,7 @@ const Navbar = () => {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{isOwnerOrAdmin && createPortal(<InviteTeamMembersModal />, document.body, 'invite-team-members-modal')}
|
||||
{isOwnerOrAdmin && createPortal(<InviteTeamMembers />, document.body, 'invite-team-members')}
|
||||
{createPortal(<NotificationDrawer />, document.body, 'notification-drawer')}
|
||||
</Col>
|
||||
);
|
||||
|
||||
70
worklenz-frontend/src/hooks/useUserTimezone.ts
Normal file
70
worklenz-frontend/src/hooks/useUserTimezone.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook to get and manage user's timezone
|
||||
* @returns {Object} Object containing timezone and related utilities
|
||||
*/
|
||||
export const useUserTimezone = () => {
|
||||
const [timezone, setTimezone] = useState<string>('UTC');
|
||||
const [timezoneOffset, setTimezoneOffset] = useState<string>('+00:00');
|
||||
|
||||
useEffect(() => {
|
||||
// Get browser's timezone
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
setTimezone(browserTimezone);
|
||||
|
||||
// Calculate timezone offset
|
||||
const date = new Date();
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const hours = Math.floor(Math.abs(offset) / 60);
|
||||
const minutes = Math.abs(offset) % 60;
|
||||
const sign = offset >= 0 ? '+' : '-';
|
||||
const formattedOffset = `${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
setTimezoneOffset(formattedOffset);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Format a date in the user's timezone
|
||||
* @param date - Date to format
|
||||
* @param format - Format options
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
const formatInUserTimezone = (date: Date | string, format?: Intl.DateTimeFormatOptions) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return dateObj.toLocaleString('en-US', {
|
||||
timeZone: timezone,
|
||||
...format
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the start of day in user's timezone
|
||||
* @param date - Date to get start of day for
|
||||
* @returns Date object representing start of day
|
||||
*/
|
||||
const getStartOfDayInTimezone = (date: Date = new Date()) => {
|
||||
const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
localDate.setHours(0, 0, 0, 0);
|
||||
return localDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the end of day in user's timezone
|
||||
* @param date - Date to get end of day for
|
||||
* @returns Date object representing end of day
|
||||
*/
|
||||
const getEndOfDayInTimezone = (date: Date = new Date()) => {
|
||||
const localDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
||||
localDate.setHours(23, 59, 59, 999);
|
||||
return localDate;
|
||||
};
|
||||
|
||||
return {
|
||||
timezone,
|
||||
timezoneOffset,
|
||||
formatInUserTimezone,
|
||||
getStartOfDayInTimezone,
|
||||
getEndOfDayInTimezone,
|
||||
setTimezone
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Card, Flex, Typography, Tooltip } from '@/shared/antd-imports';
|
||||
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
|
||||
import MembersTimeSheet, {
|
||||
MembersTimeSheetRef,
|
||||
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
|
||||
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useRef } from 'react';
|
||||
import { useUserTimezone } from '@/hooks/useUserTimezone';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MembersTimeReports = () => {
|
||||
const { t } = useTranslation('time-report');
|
||||
const chartRef = useRef<MembersTimeSheetRef>(null);
|
||||
const { timezone, timezoneOffset } = useUserTimezone();
|
||||
|
||||
useDocumentTitle('Reporting - Allocation');
|
||||
|
||||
const handleExport = (type: string) => {
|
||||
if (type === 'png') {
|
||||
chartRef.current?.exportChart();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical>
|
||||
<TimeReportingRightHeader
|
||||
title={t('Members Time Sheet')}
|
||||
exportType={[{ key: 'png', label: 'PNG' }]}
|
||||
export={handleExport}
|
||||
/>
|
||||
|
||||
<Card
|
||||
style={{ borderRadius: '4px' }}
|
||||
title={
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<TimeReportPageHeader />
|
||||
<Tooltip
|
||||
title={`All time logs are displayed in your local timezone. Times were logged by users in their respective timezones and converted for your viewing.`}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<InfoCircleOutlined style={{ marginRight: 4 }} />
|
||||
Timezone: {timezone} ({timezoneOffset})
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'calc(100vh - 300px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MembersTimeSheet ref={chartRef} />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersTimeReports;
|
||||
@@ -67,8 +67,6 @@ import {
|
||||
Radio,
|
||||
} from 'antd/es';
|
||||
|
||||
import TextArea from 'antd/es/input/TextArea';
|
||||
|
||||
// Icons - Import commonly used ones
|
||||
export {
|
||||
EditOutlined,
|
||||
@@ -242,7 +240,6 @@ export {
|
||||
Timeline,
|
||||
Mentions,
|
||||
Radio,
|
||||
TextArea
|
||||
};
|
||||
|
||||
// TypeScript Types - Import commonly used ones
|
||||
@@ -266,7 +263,6 @@ export type {
|
||||
PaginationProps,
|
||||
CollapseProps,
|
||||
TablePaginationConfig,
|
||||
CheckboxChangeEvent
|
||||
} from 'antd/es';
|
||||
|
||||
// Dayjs
|
||||
|
||||
@@ -4,5 +4,4 @@ export interface ITeamMemberCreateRequest extends ITeamMember {
|
||||
job_title?: string | null;
|
||||
emails?: string | string[];
|
||||
is_admin?: boolean;
|
||||
is_guest?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user