From 5c1cada2d05ceaf17f3b3ae46a765f88f7bcf6f5 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Sun, 17 Nov 2024 10:01:22 +0200 Subject: [PATCH 1/6] [Feat]: Add Delete Task Timesheet function (#3338) * Add deleteTaskTimesheet function to handle timesheet deletion * fix: coderabbitai * fix: build * feat: make AlertDialogConfirmation fully controllable via external props * fix: coderabbitai * fix: coderabbitai --- .../[memberId]/components/TimesheetAction.tsx | 12 +- .../api/timer/timesheet/bulk-delete/route.ts | 42 ++++++ .../hooks/features/useTimelogFilterOptions.ts | 15 +- apps/web/app/hooks/features/useTimesheet.ts | 47 +++++- .../services/client/api/timer/timer-log.ts | 41 +++++- .../app/services/server/requests/timesheet.ts | 16 ++ apps/web/app/stores/time-logs.ts | 1 + apps/web/components/ui/alert-dialog.tsx | 139 ++++++++++++++++++ apps/web/components/ui/button.tsx | 89 +++++------ .../components/alert-dialog-confirmation.tsx | 72 +++++++++ apps/web/lib/components/index.ts | 1 + .../calendar/table-time-sheet.tsx | 50 ++++++- apps/web/lib/features/user-profile-plans.tsx | 5 +- apps/web/package.json | 9 +- yarn.lock | 14 +- 15 files changed, 484 insertions(+), 69 deletions(-) create mode 100644 apps/web/app/api/timer/timesheet/bulk-delete/route.ts create mode 100755 apps/web/components/ui/alert-dialog.tsx create mode 100644 apps/web/lib/components/alert-dialog-confirmation.tsx diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx index 2bad95b8a..213b9ccc0 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx @@ -24,23 +24,25 @@ export const TimesheetButton = ({ className, icon, onClick, title }: ITimesheetB export type StatusType = "Pending" | "Approved" | "Rejected"; +export type StatusAction = "Deleted" | "Approved" | "Rejected"; + // eslint-disable-next-line @typescript-eslint/no-empty-function -export const getTimesheetButtons = (status: StatusType, t: TranslationHooks, onClick: (action: StatusType) => void) => { +export const getTimesheetButtons = (status: StatusType, t: TranslationHooks, onClick: (action: StatusAction) => void) => { - const buttonsConfig: Record = { + const buttonsConfig: Record = { Pending: [ { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_APPROVE_SELECTED'), action: "Approved" }, { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_REJECT_SELECTED'), action: "Rejected" }, - { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Pending" } + { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Deleted" } ], Approved: [ { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_REJECT_SELECTED'), action: "Rejected" }, - { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Pending" } + { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Deleted" } ], Rejected: [ { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_APPROVE_SELECTED'), action: "Approved" }, - { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Pending" } + { icon: , title: t('pages.timesheet.TIMESHEET_ACTION_DELETE_SELECTED'), action: "Deleted" } ] }; diff --git a/apps/web/app/api/timer/timesheet/bulk-delete/route.ts b/apps/web/app/api/timer/timesheet/bulk-delete/route.ts new file mode 100644 index 000000000..517e1f624 --- /dev/null +++ b/apps/web/app/api/timer/timesheet/bulk-delete/route.ts @@ -0,0 +1,42 @@ +import { deleteTaskTimesheetRequest } from '@/app/services/server/requests'; +import { authenticatedGuard } from '@app/services/server/guards/authenticated-guard-app'; +import { NextResponse } from "next/server"; + +export async function DELETE(req: Request) { + const res = new NextResponse(); + const body = await req.json(); + const { logIds = [] } = body; + + if (!Array.isArray(logIds) || logIds.length === 0) { + return NextResponse.json( + { error: 'logIds must be a non-empty array' }, + { status: 400 } + ); + } + + const { $res, user, tenantId, organizationId, access_token, } = await authenticatedGuard(req, res); + if (!user) return $res('Unauthorized'); + try { + const { data } = await deleteTaskTimesheetRequest({ + tenantId, + organizationId, + logIds, + }, access_token); + + if (!data) { + return NextResponse.json( + { error: 'No data found' }, + { status: 404 } + ); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Error delete timesheet:', error); + return NextResponse.json( + { error: 'Failed to delete timesheet data' }, + { status: 500 } + ); + } + +} diff --git a/apps/web/app/hooks/features/useTimelogFilterOptions.ts b/apps/web/app/hooks/features/useTimelogFilterOptions.ts index 60733481f..ca39217a3 100644 --- a/apps/web/app/hooks/features/useTimelogFilterOptions.ts +++ b/apps/web/app/hooks/features/useTimelogFilterOptions.ts @@ -1,16 +1,24 @@ -import { timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState } from '@/app/stores'; +import { timesheetDeleteState, timesheetFilterEmployeeState, timesheetFilterProjectState, timesheetFilterStatusState, timesheetFilterTaskState } from '@/app/stores'; import { useAtom } from 'jotai'; +import React from 'react'; export function useTimelogFilterOptions() { const [employeeState, setEmployeeState] = useAtom(timesheetFilterEmployeeState); const [projectState, setProjectState] = useAtom(timesheetFilterProjectState); const [statusState, setStatusState] = useAtom(timesheetFilterStatusState); const [taskState, setTaskState] = useAtom(timesheetFilterTaskState); + const [selectTimesheet, setSelectTimesheet] = useAtom(timesheetDeleteState); const employee = employeeState; const project = projectState; const task = taskState + const handleSelectRowTimesheet = (items: string) => { + setSelectTimesheet((prev) => prev.includes(items) ? prev.filter((filter) => filter !== items) : [...prev, items]) + } + React.useEffect(() => { + return () => setSelectTimesheet([]); + }, []); return { statusState, @@ -20,6 +28,9 @@ export function useTimelogFilterOptions() { setEmployeeState, setProjectState, setTaskState, - setStatusState + setStatusState, + handleSelectRowTimesheet, + selectTimesheet, + setSelectTimesheet }; } diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index c11e196ff..959f2ccdd 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -3,7 +3,7 @@ import { useAtom } from 'jotai'; import { timesheetRapportState } from '@/app/stores/time-logs'; import { useQuery } from '../useQuery'; import { useCallback, useEffect } from 'react'; -import { getTaskTimesheetLogsApi } from '@/app/services/client/api/timer/timer-log'; +import { deleteTaskTimesheetLogsApi, getTaskTimesheetLogsApi } from '@/app/services/client/api/timer/timer-log'; import moment from 'moment'; import { ITimeSheet } from '@/app/interfaces'; import { useTimelogFilterOptions } from './useTimelogFilterOptions'; @@ -17,7 +17,11 @@ export interface GroupedTimesheet { date: string; tasks: ITimeSheet[]; } - +interface DeleteTimesheetParams { + organizationId: string; + tenantId: string; + logIds: string[]; +} const groupByDate = (items: ITimeSheet[]): GroupedTimesheet[] => { if (!items?.length) return []; @@ -46,8 +50,10 @@ export function useTimesheet({ }: TimesheetParams) { const { user } = useAuthenticateUser(); const [timesheet, setTimesheet] = useAtom(timesheetRapportState); - const { employee, project } = useTimelogFilterOptions(); + const { employee, project, selectTimesheet: logIds } = useTimelogFilterOptions(); const { loading: loadingTimesheet, queryCall: queryTimesheet } = useQuery(getTaskTimesheetLogsApi); + const { loading: loadingDeleteTimesheet, queryCall: queryDeleteTimesheet } = useQuery(deleteTaskTimesheetLogsApi) + const getTaskTimesheet = useCallback( ({ startDate, endDate }: TimesheetParams) => { @@ -76,6 +82,39 @@ export function useTimesheet({ project ] ); + + + + const handleDeleteTimesheet = async (params: DeleteTimesheetParams) => { + try { + return await queryDeleteTimesheet(params); + } catch (error) { + console.error('Error deleting timesheet:', error); + throw error; + } + }; + + const deleteTaskTimesheet = useCallback(async () => { + if (!user) { + throw new Error('User not authenticated'); + } + if (!logIds.length) { + throw new Error('No timesheet IDs provided for deletion'); + } + try { + await handleDeleteTimesheet({ + organizationId: user.employee.organizationId, + tenantId: user.tenantId ?? "", + logIds + }); + } catch (error) { + console.error('Failed to delete timesheets:', error); + throw error; + } + }, + [user, queryDeleteTimesheet, logIds, handleDeleteTimesheet] // deepscan-disable-line + ); + useEffect(() => { getTaskTimesheet({ startDate, endDate }); }, [getTaskTimesheet, startDate, endDate]); @@ -86,5 +125,7 @@ export function useTimesheet({ loadingTimesheet, timesheet: groupByDate(timesheet), getTaskTimesheet, + loadingDeleteTimesheet, + deleteTaskTimesheet }; } diff --git a/apps/web/app/services/client/api/timer/timer-log.ts b/apps/web/app/services/client/api/timer/timer-log.ts index f3a5b13b4..3715f8cc3 100644 --- a/apps/web/app/services/client/api/timer/timer-log.ts +++ b/apps/web/app/services/client/api/timer/timer-log.ts @@ -1,5 +1,5 @@ import { ITimeSheet, ITimerStatus } from '@app/interfaces'; -import { get } from '../../axios'; +import { get, deleteApi } from '../../axios'; export async function getTimerLogs( tenantId: string, @@ -66,3 +66,42 @@ export async function getTaskTimesheetLogsApi({ const endpoint = `/timesheet/time-log?${params.toString()}`; return get(endpoint, { tenantId }); } + + +export async function deleteTaskTimesheetLogsApi({ + logIds, + organizationId, + tenantId +}: { + organizationId: string, + tenantId: string, + logIds: string[] +}) { + // Validate required parameters + if (!organizationId || !tenantId || !logIds?.length) { + throw new Error('Required parameters missing: organizationId, tenantId, and logIds are required'); + } + + // Limit bulk deletion size for safety + if (logIds.length > 100) { + throw new Error('Maximum 100 logs can be deleted at once'); + } + + const params = new URLSearchParams({ + organizationId, + tenantId + }); + logIds.forEach((id, index) => { + if (!id) { + throw new Error(`Invalid logId at index ${index}`); + } + params.append(`logIds[${index}]`, id); + }); + + const endPoint = `/timesheet/time-log?${params.toString()}`; + try { + return await deleteApi<{ success: boolean; message: string }>(endPoint, { tenantId }); + } catch (error) { + throw new Error(`Failed to delete timesheet logs`); + } +} diff --git a/apps/web/app/services/server/requests/timesheet.ts b/apps/web/app/services/server/requests/timesheet.ts index a7a254880..1c5fe6048 100644 --- a/apps/web/app/services/server/requests/timesheet.ts +++ b/apps/web/app/services/server/requests/timesheet.ts @@ -79,3 +79,19 @@ export function getTaskTimesheetRequest(params: ITimesheetProps, bearer_token: s tenantId: params.tenantId }) } + +type IDeleteTimesheetProps = { + organizationId: string; + tenantId: string; + logIds?: string[] +} + +export function deleteTaskTimesheetRequest(params: IDeleteTimesheetProps, bearer_token: string) { + const { logIds = [] } = params; + return serverFetch({ + path: `/timesheet/time-log/${logIds.join(',')}`, + method: 'DELETE', + bearer_token, + tenantId: params.tenantId + }); +} diff --git a/apps/web/app/stores/time-logs.ts b/apps/web/app/stores/time-logs.ts index 9f607b4ff..e73d66c64 100644 --- a/apps/web/app/stores/time-logs.ts +++ b/apps/web/app/stores/time-logs.ts @@ -16,3 +16,4 @@ export const timesheetFilterProjectState = atom([]); export const timesheetFilterTaskState = atom([]); export const timesheetFilterStatusState = atom([]); +export const timesheetDeleteState = atom([]) diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx new file mode 100755 index 000000000..61b539897 --- /dev/null +++ b/apps/web/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "lib/utils" +import { buttonVariants } from "components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx index 90859cca5..670456047 100644 --- a/apps/web/components/ui/button.tsx +++ b/apps/web/components/ui/button.tsx @@ -1,51 +1,56 @@ -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; -import * as React from 'react'; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from 'lib/utils'; +import { cn } from "lib/utils" const buttonVariants = cva( - 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground', - link: 'text-primary underline-offset-4 hover:underline' - }, - size: { - default: 'h-10 px-4 py-2', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10' - } - }, - defaultVariants: { - variant: 'default', - size: 'default' - } - } -); + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) export interface ButtonProps - extends React.ButtonHTMLAttributes, - VariantProps { - asChild?: boolean; + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }: ButtonProps, ref) => { - const Comp = asChild ? Slot : 'button'; - return ( - - {props.children as React.ReactNode} - - ); - } -); -Button.displayName = 'Button'; + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/apps/web/lib/components/alert-dialog-confirmation.tsx b/apps/web/lib/components/alert-dialog-confirmation.tsx new file mode 100644 index 000000000..494a40f12 --- /dev/null +++ b/apps/web/lib/components/alert-dialog-confirmation.tsx @@ -0,0 +1,72 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@components/ui/alert-dialog" +import { ReloadIcon } from "@radix-ui/react-icons"; +import React from "react"; + + + +interface AlertDialogConfirmationProps { + title: string; + description: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onCancel: () => void; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + loading?: boolean +} + +export function AlertDialogConfirmation({ + title, + description, + confirmText = "Continue", + cancelText = "Cancel", + onConfirm, + onCancel, + isOpen, + onOpenChange, + loading +}: AlertDialogConfirmationProps) { + return ( + + + + {title} + {description} + + + + {cancelText} + + + {loading && ( + + )} + {loading ? "Processing..." : confirmText} + + + + + ); +} diff --git a/apps/web/lib/components/index.ts b/apps/web/lib/components/index.ts index c9f001e4a..356270efd 100644 --- a/apps/web/lib/components/index.ts +++ b/apps/web/lib/components/index.ts @@ -30,3 +30,4 @@ export * from './inputs/auth-code-input'; export * from './services/recaptcha'; export * from './copy-tooltip'; export * from './alert-popup' +export * from './alert-dialog-confirmation' diff --git a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx index 83468b5bb..bd937ee60 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -44,7 +44,7 @@ import { MdKeyboardArrowRight } from "react-icons/md" import { ConfirmStatusChange, StatusBadge, statusOptions, dataSourceTimeSheet, TimeSheet } from "." -import { useModal } from "@app/hooks" +import { useModal, useTimelogFilterOptions } from "@app/hooks" import { Checkbox } from "@components/ui/checkbox" import { Accordion, @@ -53,12 +53,12 @@ import { AccordionTrigger, } from "@components/ui/accordion" import { clsxm } from "@/app/utils" -import { statusColor } from "@/lib/components" +import { AlertDialogConfirmation, statusColor } from "@/lib/components" import { Badge } from '@components/ui/badge' -import { EditTaskModal, RejectSelectedModal, StatusType, getTimesheetButtons } from "@/app/[locale]/timesheet/[memberId]/components" +import { EditTaskModal, RejectSelectedModal, StatusAction, StatusType, getTimesheetButtons } from "@/app/[locale]/timesheet/[memberId]/components" import { useTranslations } from "next-intl" import { formatDate } from "@/app/helpers" -import { GroupedTimesheet } from "@/app/hooks/features/useTimesheet" +import { GroupedTimesheet, useTimesheet } from "@/app/hooks/features/useTimesheet" @@ -178,6 +178,26 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { openModal, closeModal } = useModal(); + const { deleteTaskTimesheet, loadingDeleteTimesheet } = useTimesheet({}) + const { handleSelectRowTimesheet, selectTimesheet, setSelectTimesheet } = useTimelogFilterOptions() + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const handleConfirm = () => { + try { + deleteTaskTimesheet() + .then(() => { + setSelectTimesheet([]) + setIsDialogOpen(false); + }) + .catch((error) => { + console.error('Delete timesheet error:', error); + }); + } catch (error) { + console.error('Delete timesheet error:', error); + } + }; + const handleCancel = () => { + setIsDialogOpen(false); + }; const t = useTranslations(); const [sorting, setSorting] = React.useState([]) @@ -209,7 +229,7 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { Rejected: table.getRowModel().rows.filter(row => row.original.status === "Rejected") }; - const handleButtonClick = (action: StatusType) => { + const handleButtonClick = (action: StatusAction) => { switch (action) { case 'Approved': // TODO: Implement approval logic @@ -217,8 +237,8 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { case 'Rejected': openModal() break; - case 'Pending': - // TODO: Implement pending logic + case 'Deleted': + setIsDialogOpen(true) break; default: console.error(`Unsupported action: ${action}`); @@ -227,6 +247,17 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { return (
+ { // Pending implementation @@ -283,7 +314,10 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { style={{ backgroundColor: statusColor(status).bgOpacity, borderBottomColor: statusColor(status).bg }} className={clsxm("flex items-center border-b border-b-gray-200 dark:border-b-gray-600 space-x-4 p-1 h-[60px]")} > - + handleSelectRowTimesheet(task.id)} + checked={selectTimesheet.includes(task.id)} + />
{/* {/* Planned Time */} diff --git a/apps/web/package.json b/apps/web/package.json index 7e36362dc..32e4781f6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,6 +52,7 @@ "@opentelemetry/semantic-conventions": "^1.18.1", "@popperjs/core": "^2.11.6", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", @@ -98,7 +99,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.42", "nanoid": "5.0.1", - "next": "14.2.17", + "next": "14.2.17", "next-auth": "^5.0.0-beta.18", "next-intl": "^3.3.2", "next-themes": "^0.2.1", @@ -132,9 +133,6 @@ "tailwind-merge": "^1.14.0" }, "devDependencies": { - "tailwindcss-animate": "^1.0.6", - "tailwindcss": "^3.4.1", - "postcss": "^8.4.19", "@svgr/webpack": "^8.1.0", "@tailwindcss/typography": "^0.5.9", "@types/cookie": "^0.5.1", @@ -151,6 +149,9 @@ "eslint": "^8.28.0", "eslint-config-next": "^14.0.4", "eslint-plugin-unused-imports": "^3.0.0", + "postcss": "^8.4.19", + "tailwindcss": "^3.4.1", + "tailwindcss-animate": "^1.0.6", "typescript": "^4.9.4" }, "prettier": { diff --git a/yarn.lock b/yarn.lock index d416b8b87..6e68a578d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6050,6 +6050,18 @@ "@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-use-controllable-state" "1.0.1" +"@radix-ui/react-alert-dialog@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz#ac3bb7f71f5cbb595d3d0949bb12b598c2a99981" + integrity sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dialog" "1.1.2" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-arrow@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d" @@ -6203,7 +6215,7 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.4" -"@radix-ui/react-dialog@^1.1.2": +"@radix-ui/react-dialog@1.1.2", "@radix-ui/react-dialog@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz#d9345575211d6f2d13e209e84aec9a8584b54d6c" integrity sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA== From 0fdda8aa2cd9eea4305c1e870dd43ac5fd66ab46 Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Mon, 18 Nov 2024 21:18:55 +0200 Subject: [PATCH 2/6] [Feat]: Display Timesheet Data and Refactor Code (#3342) * feat: display timesheet data and refactor code * fix: codeRabbit * Update apps/web/lib/features/task/task-card.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Ruslan Konviser Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../[memberId]/components/TimesheetAction.tsx | 28 +-- .../components/TimesheetFilterDate.tsx | 2 +- apps/web/app/hooks/features/useTimesheet.ts | 58 +++++- apps/web/app/interfaces/ITask.ts | 8 + apps/web/app/interfaces/timer/ITimerLog.ts | 110 ++++++---- .../services/client/api/timer/timer-log.ts | 8 +- .../app/services/server/requests/timesheet.ts | 6 +- apps/web/app/stores/time-logs.ts | 4 +- apps/web/lib/components/types.ts | 30 ++- .../calendar/table-time-sheet.tsx | 196 ++++++++++-------- apps/web/lib/features/task/task-card.tsx | 54 ++--- apps/web/lib/features/task/task-issue.tsx | 18 +- 12 files changed, 316 insertions(+), 206 deletions(-) diff --git a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx index 213b9ccc0..5e1de381d 100644 --- a/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx +++ b/apps/web/app/[locale]/timesheet/[memberId]/components/TimesheetAction.tsx @@ -8,12 +8,15 @@ type ITimesheetButton = { title?: string, onClick?: () => void, className?: string, - icon?: ReactNode + icon?: ReactNode, + disabled?: boolean } -export const TimesheetButton = ({ className, icon, onClick, title }: ITimesheetButton) => { +export const TimesheetButton = ({ className, icon, onClick, title, disabled }: ITimesheetButton) => { return ( -
) } -
+ {isVisible &&
}
{[ t('common.FILTER_TODAY'), diff --git a/apps/web/app/hooks/features/useTimesheet.ts b/apps/web/app/hooks/features/useTimesheet.ts index 959f2ccdd..3352f9861 100644 --- a/apps/web/app/hooks/features/useTimesheet.ts +++ b/apps/web/app/hooks/features/useTimesheet.ts @@ -5,7 +5,7 @@ import { useQuery } from '../useQuery'; import { useCallback, useEffect } from 'react'; import { deleteTaskTimesheetLogsApi, getTaskTimesheetLogsApi } from '@/app/services/client/api/timer/timer-log'; import moment from 'moment'; -import { ITimeSheet } from '@/app/interfaces'; +import { TimesheetLog, TimesheetStatus } from '@/app/interfaces'; import { useTimelogFilterOptions } from './useTimelogFilterOptions'; interface TimesheetParams { @@ -15,25 +15,35 @@ interface TimesheetParams { export interface GroupedTimesheet { date: string; - tasks: ITimeSheet[]; + tasks: TimesheetLog[]; } + + interface DeleteTimesheetParams { organizationId: string; tenantId: string; logIds: string[]; } -const groupByDate = (items: ITimeSheet[]): GroupedTimesheet[] => { + +const groupByDate = (items: TimesheetLog[]): GroupedTimesheet[] => { if (!items?.length) return []; - type GroupedMap = Record; + type GroupedMap = Record; + const groupedByDate = items.reduce((acc, item) => { - if (!item?.createdAt) return acc; + if (!item?.timesheet?.createdAt) { + console.warn('Skipping item with missing timesheet or createdAt:', item); + return acc; + } try { - const date = new Date(item.createdAt).toISOString().split('T')[0]; + const date = new Date(item.timesheet.createdAt).toISOString().split('T')[0]; if (!acc[date]) acc[date] = []; acc[date].push(item); } catch (error) { - console.error('Invalid date format:', item.createdAt); + console.error( + `Failed to process date for timesheet ${item.timesheet.id}:`, + { createdAt: item.timesheet.createdAt, error } + ); } return acc; }, {}); @@ -83,6 +93,37 @@ export function useTimesheet({ ] ); + const getStatusTimesheet = (items: TimesheetLog[] = []) => { + const STATUS_MAP: Record = { + PENDING: [], + APPROVED: [], + DENIED: [], + DRAFT: [], + 'IN REVIEW': [] + }; + + return items.reduce((acc, item) => { + const status = item.timesheet.status; + if (isTimesheetStatus(status)) { + acc[status].push(item); + } else { + console.warn(`Invalid timesheet status: ${status}`); + } + return acc; + }, STATUS_MAP); + } + + // Type guard + function isTimesheetStatus(status: unknown): status is TimesheetStatus { + const timesheetStatusValues: TimesheetStatus[] = [ + "DRAFT", + "PENDING", + "IN REVIEW", + "DENIED", + "APPROVED" + ]; + return Object.values(timesheetStatusValues).includes(status as TimesheetStatus); + } const handleDeleteTimesheet = async (params: DeleteTimesheetParams) => { @@ -126,6 +167,7 @@ export function useTimesheet({ timesheet: groupByDate(timesheet), getTaskTimesheet, loadingDeleteTimesheet, - deleteTaskTimesheet + deleteTaskTimesheet, + getStatusTimesheet }; } diff --git a/apps/web/app/interfaces/ITask.ts b/apps/web/app/interfaces/ITask.ts index daa407299..0bb4459b3 100644 --- a/apps/web/app/interfaces/ITask.ts +++ b/apps/web/app/interfaces/ITask.ts @@ -137,6 +137,14 @@ export type ITaskStatusField = | 'tags' | 'status type'; +export type TimesheetStatus = + | "DRAFT" + | "PENDING" + | "IN REVIEW" + | "DENIED" + | "APPROVED"; + + export type ITaskStatusStack = { status: ITaskStatus; size: ITaskSize; diff --git a/apps/web/app/interfaces/timer/ITimerLog.ts b/apps/web/app/interfaces/timer/ITimerLog.ts index 3e30da712..d6e469d25 100644 --- a/apps/web/app/interfaces/timer/ITimerLog.ts +++ b/apps/web/app/interfaces/timer/ITimerLog.ts @@ -1,66 +1,99 @@ -import { ITaskIssue } from ".."; +import { ITeamTask } from "../ITask"; -interface Project { +interface BaseEntity { id: string; - name: string; - imageUrl: string; - membersCount: number; - image: string | null; -} - -interface Task { - id: string; - title: string; - issueType?: ITaskIssue | null; - estimate: number | null; - taskStatus: string | null; - taskNumber: string; + isActive: boolean; + isArchived: boolean; + tenantId: string; + organizationId: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + archivedAt: string | null; } -interface OrganizationContact { - id: string; - name: string; - imageUrl: string; +interface ImageEntity { + imageUrl: string | null; image: string | null; } -interface User { - id: string; +interface User extends BaseEntity { firstName: string; lastName: string; - imageUrl: string; - image: string | null; name: string; + imageUrl: string | null; + image: string | null; } -interface Employee { - id: string; +interface Employee extends BaseEntity { isOnline: boolean; isAway: boolean; user: User; fullName: string; } -export interface ITimeSheet { - deletedAt: string | null; - id: string; - createdAt: string; - updatedAt: string; - isActive: boolean; - isArchived: boolean; - archivedAt: string | null; - tenantId: string; - organizationId: string; +interface TaskStatus extends BaseEntity { + name: string; + value: string; + description: string; + order: number; + icon: string; + color: string; + isSystem: boolean; + isCollapsed: boolean; + isDefault: boolean; + isTodo: boolean; + isInProgress: boolean; + isDone: boolean; + projectId: string | null; + organizationTeamId: string | null; + fullIconUrl: string; +} +interface Task extends ITeamTask { + taskStatus: TaskStatus | null, + number: number; + description: string; + startDate: string | null; +} + + +interface Timesheet extends BaseEntity { + duration: number; + keyboard: number; + mouse: number; + overall: number; + startedAt: string; + stoppedAt: string; + approvedAt: string | null; + submittedAt: string | null; + lockedAt: string | null; + editedAt: string | null; + isBilled: boolean; + status: string; + employeeId: string; + approvedById: string | null; + isEdited: boolean; +} +interface Project extends BaseEntity, ImageEntity { + name: string; + membersCount: number; +} + +interface OrganizationContact extends BaseEntity, ImageEntity { + name: string; +} + +export interface TimesheetLog extends BaseEntity { startedAt: string; stoppedAt: string; editedAt: string | null; - logType: string; - source: string; + logType: "TRACKED" | "MANUAL"; + source: "WEB_TIMER" | "MOBILE_APP" | "DESKTOP_APP"; description: string; reason: string | null; isBillable: boolean; isRunning: boolean; - version: number | null; + version: string | null; employeeId: string; timesheetId: string; projectId: string; @@ -71,6 +104,7 @@ export interface ITimeSheet { task: Task; organizationContact: OrganizationContact; employee: Employee; + timesheet: Timesheet, duration: number; isEdited: boolean; } diff --git a/apps/web/app/services/client/api/timer/timer-log.ts b/apps/web/app/services/client/api/timer/timer-log.ts index 3715f8cc3..b35b7abea 100644 --- a/apps/web/app/services/client/api/timer/timer-log.ts +++ b/apps/web/app/services/client/api/timer/timer-log.ts @@ -1,4 +1,4 @@ -import { ITimeSheet, ITimerStatus } from '@app/interfaces'; +import { TimesheetLog, ITimerStatus } from '@app/interfaces'; import { get, deleteApi } from '../../axios'; export async function getTimerLogs( @@ -53,7 +53,9 @@ export async function getTaskTimesheetLogsApi({ 'relations[1]': 'task', 'relations[2]': 'organizationContact', 'relations[3]': 'employee.user', - 'relations[4]': 'task.taskStatus' + 'relations[4]': 'task.taskStatus', + 'relations[5]': 'timesheet' + }); projectIds.forEach((id, index) => { @@ -64,7 +66,7 @@ export async function getTaskTimesheetLogsApi({ params.append(`employeeIds[${index}]`, id); }); const endpoint = `/timesheet/time-log?${params.toString()}`; - return get(endpoint, { tenantId }); + return get(endpoint, { tenantId }); } diff --git a/apps/web/app/services/server/requests/timesheet.ts b/apps/web/app/services/server/requests/timesheet.ts index 1c5fe6048..4040697dd 100644 --- a/apps/web/app/services/server/requests/timesheet.ts +++ b/apps/web/app/services/server/requests/timesheet.ts @@ -1,7 +1,7 @@ import { ITasksTimesheet } from '@app/interfaces/ITimer'; import { serverFetch } from '../fetch'; import qs from 'qs'; -import { ITimeSheet } from '@/app/interfaces/timer/ITimerLog'; +import { TimesheetLog } from '@/app/interfaces/timer/ITimerLog'; export type TTasksTimesheetStatisticsParams = { tenantId: string; @@ -72,7 +72,7 @@ type ITimesheetProps = { export function getTaskTimesheetRequest(params: ITimesheetProps, bearer_token: string) { const queries = qs.stringify(params); - return serverFetch({ + return serverFetch({ path: `/timesheet/time-log?activityLevel?${queries.toString()}`, method: 'GET', bearer_token, @@ -88,7 +88,7 @@ type IDeleteTimesheetProps = { export function deleteTaskTimesheetRequest(params: IDeleteTimesheetProps, bearer_token: string) { const { logIds = [] } = params; - return serverFetch({ + return serverFetch({ path: `/timesheet/time-log/${logIds.join(',')}`, method: 'DELETE', bearer_token, diff --git a/apps/web/app/stores/time-logs.ts b/apps/web/app/stores/time-logs.ts index e73d66c64..d015ab9bf 100644 --- a/apps/web/app/stores/time-logs.ts +++ b/apps/web/app/stores/time-logs.ts @@ -1,6 +1,6 @@ import { ITimerLogsDailyReport } from '@app/interfaces/timer/ITimerLogs'; import { atom } from 'jotai'; -import { IProject, ITeamTask, ITimeSheet, OT_Member } from '../interfaces'; +import { IProject, ITeamTask, OT_Member, TimesheetLog } from '../interfaces'; interface IFilterOption { value: string; @@ -9,7 +9,7 @@ interface IFilterOption { export const timerLogsDailyReportState = atom([]); -export const timesheetRapportState = atom([]) +export const timesheetRapportState = atom([]) export const timesheetFilterEmployeeState = atom([]); export const timesheetFilterProjectState = atom([]); diff --git a/apps/web/lib/components/types.ts b/apps/web/lib/components/types.ts index 30755c619..30c0412ef 100644 --- a/apps/web/lib/components/types.ts +++ b/apps/web/lib/components/types.ts @@ -6,21 +6,37 @@ type StatusColorScheme = { }; const STATUS_COLORS: Record = { - Pending: { + PENDING: { bg: 'bg-[#FBB650]', text: 'text-[#FBB650]', - bgOpacity: 'rgba(251, 182, 80, 0.1)' + bgOpacity: 'rgba(251, 182, 80, 0.1)', }, - Approved: { + APPROVED: { bg: 'bg-[#30B366]', text: 'text-[#30B366]', - bgOpacity: 'rgba(48, 179, 102, 0.1)' + bgOpacity: 'rgba(48, 179, 102, 0.1)', }, - Rejected: { + DENIED: { bg: 'bg-[#dc2626]', text: 'text-[#dc2626]', - bgOpacity: 'rgba(220, 38, 38, 0.1)' - } + bgOpacity: 'rgba(220, 38, 38, 0.1)', + }, + DRAFT: { + bg: 'bg-gray-300', + text: 'text-gray-500', + bgOpacity: 'rgba(220, 220, 220, 0.1)', + }, + 'IN REVIEW': { + bg: 'bg-blue-500', + text: 'text-blue-500', + bgOpacity: 'rgba(59, 130, 246, 0.1)', + }, + DEFAULT: { + bg: 'bg-gray-100', + text: 'text-gray-400', + bgOpacity: 'rgba(243, 244, 246, 0.1)', + }, + }; diff --git a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx index bd937ee60..24f5d3202 100644 --- a/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx +++ b/apps/web/lib/features/integrations/calendar/table-time-sheet.tsx @@ -24,10 +24,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@components/ui/dropdown-menu" -import { - Table, - TableBody -} from "@components/ui/table" import { Select, SelectContent, @@ -59,8 +55,9 @@ import { EditTaskModal, RejectSelectedModal, StatusAction, StatusType, getTimesh import { useTranslations } from "next-intl" import { formatDate } from "@/app/helpers" import { GroupedTimesheet, useTimesheet } from "@/app/hooks/features/useTimesheet" - - +import { TaskNameInfoDisplay } from "../../task/task-displays" +import { TimesheetStatus } from "@/app/interfaces" +import dayjs from 'dayjs'; export const columns: ColumnDef[] = [ @@ -178,7 +175,7 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { openModal, closeModal } = useModal(); - const { deleteTaskTimesheet, loadingDeleteTimesheet } = useTimesheet({}) + const { deleteTaskTimesheet, loadingDeleteTimesheet, getStatusTimesheet } = useTimesheet({}) const { handleSelectRowTimesheet, selectTimesheet, setSelectTimesheet } = useTimelogFilterOptions() const [isDialogOpen, setIsDialogOpen] = React.useState(false); const handleConfirm = () => { @@ -198,7 +195,6 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { const handleCancel = () => { setIsDialogOpen(false); }; - const t = useTranslations(); const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState([]) @@ -223,18 +219,13 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { }, }) - const groupedRows = { - Pending: table.getRowModel().rows.filter(row => row.original.status === "Pending"), - Approved: table.getRowModel().rows.filter(row => row.original.status === "Approved"), - Rejected: table.getRowModel().rows.filter(row => row.original.status === "Rejected") - }; const handleButtonClick = (action: StatusAction) => { switch (action) { case 'Approved': // TODO: Implement approval logic break; - case 'Rejected': + case 'Denied': openModal() break; case 'Deleted': @@ -268,82 +259,93 @@ export function DataTableTimeSheet({ data }: { data?: GroupedTimesheet[] }) { isOpen={isOpen} />
- - - {data?.map((plan, index) => ( -
-
- {formatDate(plan?.date)} - 64:30h -
+ {data?.map((plan, index) => ( +
+
+ {formatDate(plan.date)} + 64:30h +
+ + + {Object.entries(getStatusTimesheet(plan.tasks)).map(([status, rows]) => ( + + +
+
+
+
+ + {status === 'DENIED' ? "REJECTED" : status} + + ({rows?.length}) +
+ + Total + 24:30h + +
+
+ {getTimesheetButtons(status as StatusType, t, true, handleButtonClick)} +
+
+
+ + {rows?.map((task) => ( +
+ handleSelectRowTimesheet(task.id)} + checked={selectTimesheet.includes(task.id)} + /> +
+ +
+ {task.project && task.project.name} +
+ + {task.employee.fullName} +
+
+ + {task.timesheet.status} + - - {Object.entries(groupedRows).map(([status, rows]) => ( - - -
-
-
-
- - {status} - - ({rows.length}) -
- - Total - 24:30h - -
-
- {getTimesheetButtons(status as StatusType, t, handleButtonClick)} -
-
- - {plan.tasks?.map((task) => ( -
- handleSelectRowTimesheet(task.id)} - checked={selectTimesheet.includes(task.id)} - /> -
- {/* */} -
- {task.isActive} - {task.employee.fullName} - - {/* {task.}h, {task.estimateDays}j, {task.estimateMinutes}m */} - - -
- ))} -
-
- ))} -
-
- ))} - -
+ + {dayjs(task.timesheet.createdAt).format("HH:mm:ss")} + + +
+ ))} + + + ))} + +
+ ))}
@@ -551,3 +553,21 @@ export const StatusTask = () => { ) } + + +const getBadgeColor = (timesheetStatus: TimesheetStatus | null) => { + switch (timesheetStatus) { + case 'DRAFT': + return 'bg-gray-300'; + case 'PENDING': + return 'bg-yellow-400'; + case 'IN REVIEW': + return 'bg-blue-500'; + case 'DENIED': + return 'bg-red-500'; + case 'APPROVED': + return 'bg-green-500'; + default: + return 'bg-gray-100'; + } +}; diff --git a/apps/web/lib/features/task/task-card.tsx b/apps/web/lib/features/task/task-card.tsx index c4d97f026..ae4d67e6f 100644 --- a/apps/web/lib/features/task/task-card.tsx +++ b/apps/web/lib/features/task/task-card.tsx @@ -646,39 +646,39 @@ function TaskCardMenu({ {(viewType == 'default' || (viewType === 'dailyplan' && planMode === 'Outstanding')) && ( - <> - -
- {!taskPlannedToday && ( -
  • - -
  • - )} - {!taskPlannedTomorrow && ( + <> + +
    + {!taskPlannedToday && ( +
  • + +
  • + )} + {!taskPlannedTomorrow && ( +
  • + +
  • + )}
  • - )} -
  • - -
  • -
    - - )} +
    + + )} {viewType === 'dailyplan' && (planMode === 'Today Tasks' || planMode === 'Future Tasks') && ( diff --git a/apps/web/lib/features/task/task-issue.tsx b/apps/web/lib/features/task/task-issue.tsx index 1b494a2f8..8c13db8ed 100644 --- a/apps/web/lib/features/task/task-issue.tsx +++ b/apps/web/lib/features/task/task-issue.tsx @@ -1,5 +1,5 @@ import { useModal } from '@app/hooks'; -import { IClassName, IssueType, ITaskIssue, ITeamTask, ITimeSheet, Nullable } from '@app/interfaces'; +import { IClassName, IssueType, ITaskIssue, ITeamTask, Nullable } from '@app/interfaces'; import { clsxm } from '@app/utils'; import { BackButton, Button, Card, InputField, Modal, Text } from 'lib/components'; import { NoteIcon, BugIcon, Square4StackIcon, Square4OutlineIcon } from 'assets/svg'; @@ -212,19 +212,3 @@ export function CreateTaskIssueModal({ open, closeModal }: { open: boolean; clos ); } - - -export function TaskIssueStatusTimesheet({ - task, - className, - showIssueLabels -}: { task: Nullable; showIssueLabels?: boolean } & IClassName) { - return ( - - ); -} From 628a39e3e22f2872c5e5ed54e71ff733006d606a Mon Sep 17 00:00:00 2001 From: syns Date: Tue, 19 Nov 2024 02:20:08 +0700 Subject: [PATCH 3/6] Fix/server web application menu (#3341) * fix: server web application menu * fix: server web default updater setting * fix: server web platform logo dark and light * fix: server web host not trusted * fix: remove unused variable --- .../src/locales/i18n/bg/translation.json | 15 + .../src/locales/i18n/en/translation.json | 15 + .../helpers/services/libs/desktop-store.ts | 40 ++- apps/server-web/src/main/main.ts | 43 ++- apps/server-web/src/main/main_.ts | 137 --------- apps/server-web/src/main/menu.ts | 290 +++++++----------- .../components/svgs/EverTeamsLogo.tsx | 8 +- apps/web/app/constants.ts | 2 + apps/web/auth.ts | 2 + 9 files changed, 188 insertions(+), 364 deletions(-) delete mode 100644 apps/server-web/src/main/main_.ts diff --git a/apps/server-web/src/locales/i18n/bg/translation.json b/apps/server-web/src/locales/i18n/bg/translation.json index a9ba889cc..bcec1eff5 100644 --- a/apps/server-web/src/locales/i18n/bg/translation.json +++ b/apps/server-web/src/locales/i18n/bg/translation.json @@ -14,6 +14,21 @@ "OPEN_WEB": "Отворете уеб в браузъра", "SERVER_WINDOW": "Прозорец на сървъра" }, + "MENU_APP": { + "ABOUT": "Относно", + "QUIT": "Изход", + "WINDOW": "Прозорец", + "SUBMENU": { + "SETTING": "Настройки", + "SERVER_WINDOW": "Сървърен прозорец", + "LEARN_MORE": "Научете повече", + "DOC": "Документация", + "SETTING_DEV": "Настройки за разработчици", + "SERVER_DEV": "Сървър за разработчици" + }, + "DEV": "Разработчик", + "HELP": "Помощ" + }, "FORM": { "FIELDS": { "PORT": "ПРИСТАНИЩЕ", diff --git a/apps/server-web/src/locales/i18n/en/translation.json b/apps/server-web/src/locales/i18n/en/translation.json index 48320d87b..c18265dde 100644 --- a/apps/server-web/src/locales/i18n/en/translation.json +++ b/apps/server-web/src/locales/i18n/en/translation.json @@ -14,6 +14,21 @@ "OPEN_WEB": "Open Web In Browser", "SERVER_WINDOW": "Server Window" }, + "MENU_APP": { + "ABOUT": "About", + "QUIT": "Quit", + "WINDOW": "Window", + "SUBMENU": { + "SETTING": "Setting", + "SERVER_WINDOW": "Server Window", + "LEARN_MORE": "Learn More", + "DOC": "Documentation", + "SETTING_DEV": "Setting Dev.", + "SERVER_DEV": "Server Dev." + }, + "DEV": "Developer", + "HELP": "Help" + }, "FORM": { "FIELDS": { "PORT": "PORT", diff --git a/apps/server-web/src/main/helpers/services/libs/desktop-store.ts b/apps/server-web/src/main/helpers/services/libs/desktop-store.ts index 9db836cd9..01c708563 100644 --- a/apps/server-web/src/main/helpers/services/libs/desktop-store.ts +++ b/apps/server-web/src/main/helpers/services/libs/desktop-store.ts @@ -1,6 +1,19 @@ import Store from 'electron-store'; import { WebServer } from '../../interfaces'; const store = new Store(); +const DEFAULT_CONFIG:any = { + server: { + PORT: 3002, + GAUZY_API_SERVER_URL: 'http://localhost:3000', + NEXT_PUBLIC_GAUZY_API_SERVER_URL: 'http://localhost:3000', + DESKTOP_WEB_SERVER_HOSTNAME: '0.0.0.0' + }, + general: { + lang: 'en', + autoUpdate: true, + updateCheckPeriode: '1140' + } +} export const LocalStore = { getStore: (source: string | 'config'): WebServer | any => { return store.get(source); @@ -24,22 +37,15 @@ export const LocalStore = { setDefaultServerConfig: () => { - const defaultConfig: WebServer | any = store.get('config'); - if (!defaultConfig || !defaultConfig.server || !defaultConfig.general) { - const config: WebServer = { - server: { - PORT: 3002, - GAUZY_API_SERVER_URL: 'http://localhost:3000', - NEXT_PUBLIC_GAUZY_API_SERVER_URL: 'http://localhost:3000', - DESKTOP_WEB_SERVER_HOSTNAME: '0.0.0.0' - }, - general: { - lang: 'en', - autoUpdate: true, - updateCheckPeriode: '30' - } - } - store.set({ config }); - } + const defaultConfig: WebServer | any = store.get('config') || {}; + Object.keys(DEFAULT_CONFIG).forEach((key) => { + Object.keys(DEFAULT_CONFIG[key]).forEach((keySub) => { + defaultConfig[key] = defaultConfig[key] || {}; + defaultConfig[key][keySub] = defaultConfig[key][keySub] || DEFAULT_CONFIG[key][keySub]; + }) + }) + store.set({ + config: defaultConfig + }); } }; diff --git a/apps/server-web/src/main/main.ts b/apps/server-web/src/main/main.ts index f88b5cf7b..cba85f718 100644 --- a/apps/server-web/src/main/main.ts +++ b/apps/server-web/src/main/main.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { app, ipcMain, Tray, dialog, BrowserWindow, shell } from 'electron'; +import { app, ipcMain, Tray, dialog, BrowserWindow, shell, Menu } from 'electron'; import { DesktopServer } from './helpers/desktop-server'; import { LocalStore } from './helpers/services/libs/desktop-store'; import { EventEmitter } from 'events'; @@ -23,8 +23,6 @@ Object.assign(console, Log.functions); app.name = config.DESCRIPTION; - - const eventEmitter = new EventEmitter(); const controller = new AbortController(); @@ -43,6 +41,7 @@ let logWindow: BrowserWindow | null = null; let setupWindow: BrowserWindow | any = null; let SettingMenu: any = null; let ServerWindowMenu: any = null; +const appMenu = new MenuBuilder(eventEmitter) Log.hooks.push((message: any, transport) => { if (transport !== Log.transports.file) { @@ -93,6 +92,7 @@ i18nextMainBackend.on('initialized', () => { }); let trayMenuItems: any = []; +let appMenuItems: any = []; const RESOURCES_PATH = app.isPackaged ? path.join(process.resourcesPath, 'assets') @@ -182,10 +182,7 @@ const createWindow = async (type: 'SETTING_WINDOW' | 'LOG_WINDOW' | 'SETUP_WINDO settingWindow = null; SettingMenu = null }); - if (!SettingMenu) { - SettingMenu = new MenuBuilder(settingWindow); - } - SettingMenu.buildMenu(); + Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenuItems, i18nextMainBackend)) break; case 'LOG_WINDOW': logWindow = new BrowserWindow(defaultOptionWindow); @@ -196,10 +193,7 @@ const createWindow = async (type: 'SETTING_WINDOW' | 'LOG_WINDOW' | 'SETUP_WINDO logWindow = null; ServerWindowMenu = null }) - if (!ServerWindowMenu) { - ServerWindowMenu = new MenuBuilder(logWindow); - } - ServerWindowMenu.buildMenu(); + Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenuItems, i18nextMainBackend)) break; case 'SETUP_WINDOW': setupWindow = new BrowserWindow(defaultOptionWindow); @@ -218,7 +212,7 @@ const createWindow = async (type: 'SETTING_WINDOW' | 'LOG_WINDOW' | 'SETUP_WINDO const runServer = async () => { console.log('Run the Server...'); try { - const envVal = getEnvApi(); + const envVal: any = getEnvApi(); // Instantiate API and UI servers await desktopServer.start( @@ -262,14 +256,18 @@ const SendMessageToSettingWindow = (type: string, data: any) => { } const onInitApplication = () => { - LocalStore.setDefaultServerConfig(); // check and set default config + // check and set default config + LocalStore.setDefaultServerConfig(); createIntervalAutoUpdate() trayMenuItems = trayMenuItems.length ? trayMenuItems : defaultTrayMenuItem(eventEmitter); + appMenuItems = appMenuItems.length ? appMenuItems : appMenu.defaultMenu(); tray = _initTray(trayMenuItems, getAssetPath('icons/icon.png')); i18nextMainBackend.on('languageChanged', (lng) => { if (i18nextMainBackend.isInitialized) { + trayMenuItems = trayMenuItems.length ? trayMenuItems : defaultTrayMenuItem(eventEmitter); updateTrayMenu('none', {}, eventEmitter, tray, trayMenuItems, i18nextMainBackend); + Menu.setApplicationMenu(appMenu.buildDefaultTemplate(appMenuItems, i18nextMainBackend)) } }); eventEmitter.on(EventLists.webServerStart, async () => { @@ -425,6 +423,16 @@ const onInitApplication = () => { const url = `http://127.0.0.1:${envConfig?.PORT}` shell.openExternal(url) }) + + eventEmitter.on(EventLists.SETTING_WINDOW_DEV, () => { + settingWindow?.webContents.toggleDevTools(); + }) + + eventEmitter.on(EventLists.SERVER_WINDOW_DEV, () => { + logWindow?.webContents.toggleDevTools(); + }) + + eventEmitter.emit(EventLists.SERVER_WINDOW); } (async () => { @@ -452,7 +460,6 @@ ipcMain.on('message', async (event, arg) => { }) ipcMain.on(IPC_TYPES.SETTING_PAGE, async (event, arg) => { - console.log('main setting page', arg); switch (arg.type) { case SettingPageTypeMessage.saveSetting: const existingConfig = getEnvApi(); @@ -512,13 +519,6 @@ ipcMain.on(IPC_TYPES.SETTING_PAGE, async (event, arg) => { case SettingPageTypeMessage.themeChange: eventEmitter.emit(EventLists.CHANGE_THEME, arg.data) break; - default: - break; - } -}) - -ipcMain.on(IPC_TYPES.UPDATER_PAGE, (event, arg) => { - switch (arg.type) { case SettingPageTypeMessage.updateSetting: LocalStore.updateConfigSetting({ general: { @@ -529,7 +529,6 @@ ipcMain.on(IPC_TYPES.UPDATER_PAGE, (event, arg) => { createIntervalAutoUpdate() event.sender.send(IPC_TYPES.UPDATER_PAGE, { type: SettingPageTypeMessage.updateSettingResponse, data: true }) break; - default: break; } diff --git a/apps/server-web/src/main/main_.ts b/apps/server-web/src/main/main_.ts deleted file mode 100644 index 10c8220ed..000000000 --- a/apps/server-web/src/main/main_.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* eslint global-require: off, no-console: off, promise/always-return: off */ - -/** - * This module executes inside of electron's main process. You can start - * electron renderer process from here and communicate with the other processes - * through IPC. - * - * When running `npm run build` or `npm run build:main`, this file is compiled to - * `./src/main.js` using webpack. This gives us some performance wins. - */ -import path from 'path'; -import { app, BrowserWindow, shell, ipcMain } from 'electron'; -import { autoUpdater } from 'electron-updater'; -import log from 'electron-log'; -import MenuBuilder from './menu'; -import { resolveHtmlPath } from './util'; - -class AppUpdater { - constructor() { - log.transports.file.level = 'info'; - autoUpdater.logger = log; - autoUpdater.checkForUpdatesAndNotify(); - } -} - -let mainWindow: BrowserWindow | null = null; - -ipcMain.on('ipc-example', async (event, arg) => { - const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`; - console.log(msgTemplate(arg)); - event.reply('ipc-example', msgTemplate('pong')); -}); - -if (process.env.NODE_ENV === 'production') { - const sourceMapSupport = require('source-map-support'); - sourceMapSupport.install(); -} - -const isDebug = - process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true'; - -if (isDebug) { - require('electron-debug')(); -} - -const installExtensions = async () => { - const installer = require('electron-devtools-installer'); - const forceDownload = !!process.env.UPGRADE_EXTENSIONS; - const extensions = ['REACT_DEVELOPER_TOOLS']; - - return installer - .default( - extensions.map((name) => installer[name]), - forceDownload, - ) - .catch(console.log); -}; - -const createWindow = async () => { - if (isDebug) { - await installExtensions(); - } - - const RESOURCES_PATH = app.isPackaged - ? path.join(process.resourcesPath, 'assets') - : path.join(__dirname, '../../assets'); - - const getAssetPath = (...paths: string[]): string => { - return path.join(RESOURCES_PATH, ...paths); - }; - - mainWindow = new BrowserWindow({ - show: false, - width: 1024, - height: 728, - icon: getAssetPath('icon.png'), - webPreferences: { - preload: app.isPackaged - ? path.join(__dirname, 'preload.js') - : path.join(__dirname, '../../.erb/dll/preload.js'), - }, - }); - - mainWindow.loadURL(resolveHtmlPath('index.html')); - - mainWindow.on('ready-to-show', () => { - if (!mainWindow) { - throw new Error('"mainWindow" is not defined'); - } - if (process.env.START_MINIMIZED) { - mainWindow.minimize(); - } else { - mainWindow.show(); - } - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); - - const menuBuilder = new MenuBuilder(mainWindow); - menuBuilder.buildMenu(); - - // Open urls in the user's browser - mainWindow.webContents.setWindowOpenHandler((eData) => { - shell.openExternal(eData.url); - return { action: 'deny' }; - }); - - // Remove this if your app does not use auto updates - // eslint-disable-next-line - new AppUpdater(); -}; - -/** - * Add event listeners... - */ - -app.on('window-all-closed', () => { - // Respect the OSX convention of having the application in memory even - // after all windows have been closed - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app - .whenReady() - .then(() => { - createWindow(); - app.on('activate', () => { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (mainWindow === null) createWindow(); - }); - }) - .catch(console.log); diff --git a/apps/server-web/src/main/menu.ts b/apps/server-web/src/main/menu.ts index be4348ceb..cec72f148 100644 --- a/apps/server-web/src/main/menu.ts +++ b/apps/server-web/src/main/menu.ts @@ -2,216 +2,79 @@ import { app, Menu, shell, - BrowserWindow, - MenuItemConstructorOptions, } from 'electron'; import { config } from '../configs/config'; - -interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions { - selector?: string; - submenu?: DarwinMenuItemConstructorOptions[] | Menu; -} +import { EventEmitter } from 'events'; +import { EventLists } from './helpers/constant'; +import i18n from 'i18next'; export default class MenuBuilder { - mainWindow: BrowserWindow; - - constructor(mainWindow: BrowserWindow) { - this.mainWindow = mainWindow; - } - - buildMenu(): Menu { - if ( - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ) { - this.setupDevelopmentEnvironment(); - } - - const template = - process.platform === 'darwin' - ? this.buildDarwinTemplate() - : this.buildDefaultTemplate(); - - const menu = Menu.buildFromTemplate(template); - Menu.setApplicationMenu(menu); - - return menu; - } - - setupDevelopmentEnvironment(): void { - this.mainWindow.webContents.on('context-menu', (_, props) => { - const { x, y } = props; - - Menu.buildFromTemplate([ - { - label: 'Inspect element', - click: () => { - this.mainWindow.webContents.inspectElement(x, y); - }, - }, - ]).popup({ window: this.mainWindow }); - }); - } + eventEmitter: EventEmitter - buildDarwinTemplate(): MenuItemConstructorOptions[] { - const subMenuAbout: DarwinMenuItemConstructorOptions = { - label: config.DESCRIPTION || app.getName(), - submenu: [ - { - label: `About ${config.DESCRIPTION || app.getName()}`, - selector: 'orderFrontStandardAboutPanel:', - }, - { type: 'separator' }, - { - label: 'Quit', - accelerator: 'Command+Q', - click: () => { - app.quit(); - }, - }, - ], - }; - const subMenuViewDev: MenuItemConstructorOptions = { - label: 'View', - submenu: [ - { - label: 'Reload', - accelerator: 'Command+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - }, - { - label: 'Toggle Developer Tools', - accelerator: 'Alt+Command+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ], - }; - const subMenuViewProd: MenuItemConstructorOptions = { - label: 'View', - submenu: [ - { - label: 'Reload', - accelerator: 'Command+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - }, - { - label: 'Toggle Developer Tools', - accelerator: 'Alt+Command+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ], - }; - const subMenuWindow: DarwinMenuItemConstructorOptions = { - label: 'Window', - submenu: [ - { - label: 'Minimize', - accelerator: 'Command+M', - selector: 'performMiniaturize:', - }, - { label: 'Close', accelerator: 'Command+W', selector: 'performClose:' }, - { type: 'separator' }, - ], - }; - const subMenuHelp: MenuItemConstructorOptions = { - label: 'Help', - submenu: [ - { - label: 'Learn More', - click() { - shell.openExternal('https://ever.team/'); - }, - }, - { - label: 'Documentation', - click() { - shell.openExternal( - 'https://github.com/ever-co/ever-teams/blob/develop/README.md', - ); - }, - }, - { - label: 'Search Issues', - click() { - shell.openExternal('https://github.com/ever-co/ever-teams/issues'); - }, - }, - ], - }; - - const subMenuView = - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ? subMenuViewDev - : subMenuViewProd; - - return [subMenuAbout, subMenuView, subMenuWindow, subMenuHelp]; + constructor(eventEmitter: EventEmitter) { + this.eventEmitter = eventEmitter } - buildDefaultTemplate() { - const templateDefault = [ + defaultMenu() { + const isDarwin = process.platform === 'darwin'; + return [ { - label: '&File', + id: 'MENU_APP', + label: config.DESCRIPTION || app.getName(), submenu: [ { - label: '&Close', - accelerator: 'Ctrl+W', + id: 'MENU_APP_ABOUT', + label: `MENU_APP.ABOUT`, + selector: 'orderFrontStandardAboutPanel:', + click: () => { + this.eventEmitter.emit(EventLists.gotoAbout) + } + }, + { type: 'separator' }, + { + id: 'MENU_APP_QUIT', + label: 'MENU_APP.QUIT', + accelerator: isDarwin ? 'Command+Q' : 'Alt+F4', click: () => { - this.mainWindow.close(); + app.quit(); }, }, ], }, { - label: '&View', - submenu: - process.env.NODE_ENV === 'development' || - process.env.DEBUG_PROD === 'true' - ? [ - { - label: '&Reload', - accelerator: 'Ctrl+R', - click: () => { - this.mainWindow.webContents.reload(); - }, - }, - { - label: 'Toggle &Developer Tools', - accelerator: 'Alt+Ctrl+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ] - : [ - { - label: 'Toggle &Developer Tools', - accelerator: 'Alt+Ctrl+I', - click: () => { - this.mainWindow.webContents.toggleDevTools(); - }, - }, - ], + id: 'MENU_APP_WINDOW', + label: 'MENU_APP.WINDOW', + submenu: [ + { + id: 'SUBMENU_SETTING', + label: 'MENU_APP.SUBMENU.SETTING', + click: () => { + this.eventEmitter.emit(EventLists.gotoSetting); + } + }, + { + id: 'SUBMENU_SERVER', + label: 'MENU_APP.SUBMENU.SERVER_WINDOW', + click: () => { + this.eventEmitter.emit(EventLists.SERVER_WINDOW); + } + } + ] }, { - label: 'Help', + id: 'MENU_APP_HELP', + label: 'MENU_APP.HELP', submenu: [ { - label: 'Learn More', + id: 'SUBMENU_LEARN_MORE', + label: 'MENU_APP.SUBMENU.LEARN_MORE', click() { shell.openExternal(config.COMPANY_SITE_LINK); }, }, { - label: 'Documentation', + id: 'SUBMENU_DOC', + label: 'MENU_APP.SUBMENU.DOC', click() { shell.openExternal( config.COMPANY_GITHUB_LINK @@ -220,8 +83,61 @@ export default class MenuBuilder { }, ], }, - ]; + { + id: 'MENU_APP_DEV', + label: 'MENU_APP.DEV', + submenu: [ + { + id: 'SUBMENU_SETTING_DEV', + label: 'MENU_APP.SUBMENU.SETTING_DEV', + click: () => { + this.eventEmitter.emit(EventLists.SETTING_WINDOW_DEV); + }, + }, + { + id: 'SUBMENU_SERVER_DEV', + label: 'MENU_APP.SUBMENU.SERVER_DEV', + click: () => { + this.eventEmitter.emit(EventLists.SERVER_WINDOW_DEV); + }, + }, + ] + }, + ] + } - return templateDefault; + buildDefaultTemplate(menuItems: any, i18nextMainBackend: typeof i18n) { + return Menu.buildFromTemplate(this.translateAppMenu(i18nextMainBackend, menuItems)); } + + updateAppMenu(menuItem: string, context: { label?: string, enabled?: boolean}, contextMenuItems: any, i18nextMainBackend: typeof i18n) { + const menuIdx:number = contextMenuItems.findIndex((item: any) => item.id === menuItem); + if (menuIdx > -1) { + contextMenuItems[menuIdx] = {...contextMenuItems[menuIdx], ...context}; + const newMenu = [...contextMenuItems]; + Menu.setApplicationMenu(Menu.buildFromTemplate(this.translateAppMenu(i18nextMainBackend, newMenu))); + } else { + const newMenu = [...contextMenuItems]; + Menu.setApplicationMenu(Menu.buildFromTemplate(this.translateAppMenu(i18nextMainBackend, newMenu))) + } +} + +translateAppMenu(i18nextMainBackend: typeof i18n, contextMenu: any) { + return contextMenu.map((menu: any) => { + const menuCopied = {...menu}; + if (menuCopied.label) { + menuCopied.label = i18nextMainBackend.t(menuCopied.label); + } + if (menuCopied.submenu && menuCopied.submenu.length) { + menuCopied.submenu = menuCopied.submenu.map((sm: any) => { + const submenu = {...sm}; + if (submenu.label) { + submenu.label = i18nextMainBackend.t(submenu.label) + } + return submenu; + }) + } + return menuCopied; + }) +} } diff --git a/apps/server-web/src/renderer/components/svgs/EverTeamsLogo.tsx b/apps/server-web/src/renderer/components/svgs/EverTeamsLogo.tsx index 2a123bf40..3df194cda 100644 --- a/apps/server-web/src/renderer/components/svgs/EverTeamsLogo.tsx +++ b/apps/server-web/src/renderer/components/svgs/EverTeamsLogo.tsx @@ -1,6 +1,12 @@ import Logo from '../../../resources/icons/platform-logo.png'; export const EverTeamsLogo = () => { return ( - EverTeams Logo + EverTeams Logo ); }; diff --git a/apps/web/app/constants.ts b/apps/web/app/constants.ts index db8321a56..e2a2e0564 100644 --- a/apps/web/app/constants.ts +++ b/apps/web/app/constants.ts @@ -317,6 +317,8 @@ export const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET; export const TWITTER_CLIENT_ID = process.env.TWITTER_CLIENT_ID; export const TWITTER_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET; +export const IS_DESKTOP_APP = process.env.IS_DESKTOP_APP === 'true'; + // Add manual timer reason export const manualTimeReasons: ManualTimeReasons[] = [ diff --git a/apps/web/auth.ts b/apps/web/auth.ts index 56e936665..36430b60a 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -2,9 +2,11 @@ import NextAuth from 'next-auth'; import { filteredProviders } from '@app/utils/check-provider-env-vars'; import { GauzyAdapter, jwtCallback, ProviderEnum, signInCallback } from '@app/services/server/requests/OAuth'; import { NextRequest } from 'next/server'; +import { IS_DESKTOP_APP } from '@app/constants'; export const { handlers, signIn, signOut, auth } = NextAuth((request) => ({ providers: filteredProviders, + trustHost: IS_DESKTOP_APP, adapter: GauzyAdapter(request as NextRequest), session: { strategy: 'jwt' }, callbacks: { From ae9cb254aa212a31805ecb7dc0f056458e71a489 Mon Sep 17 00:00:00 2001 From: "Thierry CH." Date: Mon, 18 Nov 2024 21:21:07 +0200 Subject: [PATCH 4/6] feat: add table view for daily plan tasks (#3335) --- apps/web/components/ui/data-table.tsx | 6 +- .../features/task/daily-plan/future-tasks.tsx | 220 ++++++++++-------- .../features/task/daily-plan/past-tasks.tsx | 158 +++++++------ .../cells/task-action-menu-cell.tsx | 51 ++++ .../table-view/cells/task-estimation-cell.tsx | 24 ++ .../table-view/cells/task-info-cell.tsx | 7 + .../table-view/cells/task-times-cell.tsx | 31 +++ .../task/daily-plan/table-view/index.tsx | 75 ++++++ apps/web/lib/features/task/task-card.tsx | 4 +- .../lib/features/team-members-table-view.tsx | 1 - apps/web/lib/features/user-profile-plans.tsx | 163 +++++++------ 11 files changed, 486 insertions(+), 254 deletions(-) create mode 100644 apps/web/lib/features/task/daily-plan/table-view/cells/task-action-menu-cell.tsx create mode 100644 apps/web/lib/features/task/daily-plan/table-view/cells/task-estimation-cell.tsx create mode 100644 apps/web/lib/features/task/daily-plan/table-view/cells/task-info-cell.tsx create mode 100644 apps/web/lib/features/task/daily-plan/table-view/cells/task-times-cell.tsx create mode 100644 apps/web/lib/features/task/daily-plan/table-view/index.tsx diff --git a/apps/web/components/ui/data-table.tsx b/apps/web/components/ui/data-table.tsx index 09c74e340..8a9ceaf8e 100644 --- a/apps/web/components/ui/data-table.tsx +++ b/apps/web/components/ui/data-table.tsx @@ -86,9 +86,9 @@ function DataTable({ columns, data, footerRows, isHeader }: DataT {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )}
    diff --git a/apps/web/lib/features/task/daily-plan/future-tasks.tsx b/apps/web/lib/features/task/daily-plan/future-tasks.tsx index 0b5f6ac59..65cb07acf 100644 --- a/apps/web/lib/features/task/daily-plan/future-tasks.tsx +++ b/apps/web/lib/features/task/daily-plan/future-tasks.tsx @@ -16,6 +16,7 @@ import { filterDailyPlan } from '@app/hooks/useFilterDateRange'; import { IDailyPlan } from '@app/interfaces'; import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import { useDateRange } from '@app/hooks/useDateRange'; +import DailyPlanTasksTableView from './table-view'; export function FutureTasks({ profile }: { profile: any }) { const { deleteDailyPlan, deleteDailyPlanLoading, futurePlans } = useDailyPlan(); @@ -57,115 +58,130 @@ export function FutureTasks({ profile }: { profile: any }) { - - {(provided) => ( -
      - {plan.tasks?.map((task, index) => - view === 'CARDS' ? ( - - {(provided) => ( -
      - -
      - )} -
      - ) : ( - - {(provided) => ( -
      - -
      - )} -
      - ) - )} - <>{provided.placeholder} - {canSeeActivity ? ( -
      - + ) : ( + + {(provided) => ( +
        + {plan.tasks?.map((task, index) => + view === 'CARDS' ? ( + + {(provided) => ( +
        + +
        + )} +
        + ) : ( + + {(provided) => ( +
        + +
        + )} +
        + ) + )} + <>{provided.placeholder} + {canSeeActivity ? ( +
        + { + setPopupOpen((prev) => !prev); + setCurrentDeleteIndex(index); + }} + variant="outline" + className="px-4 py-2 text-sm font-medium text-red-600 border border-red-600 rounded-md bg-light--theme-light dark:!bg-dark--theme-light" + > + Delete this plan + + } + > + {/*button confirm*/} + {/*button cancel*/} + - } - > - {/*button confirm*/} - - {/*button cancel*/} - - -
        - ) : ( - <> - )} -
      - )} -
      +
      +
      + ) : ( + <> + )} +
    + )} +
    + )}
    ))} diff --git a/apps/web/lib/features/task/daily-plan/past-tasks.tsx b/apps/web/lib/features/task/daily-plan/past-tasks.tsx index 29ce71c3e..d4fdf8cd1 100644 --- a/apps/web/lib/features/task/daily-plan/past-tasks.tsx +++ b/apps/web/lib/features/task/daily-plan/past-tasks.tsx @@ -13,6 +13,7 @@ import { useEffect, useState } from 'react'; import { IDailyPlan } from '@app/interfaces'; import { DragDropContext, Draggable, Droppable, DroppableProvided, DroppableStateSnapshot } from 'react-beautiful-dnd'; import { useDateRange } from '@app/hooks/useDateRange'; +import DailyPlanTasksTableView from './table-view'; export function PastTasks({ profile, currentTab = 'Past Tasks' }: { profile: any; currentTab?: FilterTabs }) { const { pastPlans } = useDailyPlan(); @@ -51,79 +52,90 @@ export function PastTasks({ profile, currentTab = 'Past Tasks' }: { profile: any {/* Plan header */} - - {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( -
      - {plan.tasks?.map((task, index) => - view === 'CARDS' ? ( - - {(provided) => ( -
      - -
      - )} -
      - ) : ( - - {(provided) => ( -
      - -
      - )} -
      - ) - )} -
    - )} -
    + {view === 'TABLE' ? ( + + ) : ( + + {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( +
      + {plan.tasks?.map((task, index) => + view === 'CARDS' ? ( + + {(provided) => ( +
      + +
      + )} +
      + ) : ( + + {(provided) => ( +
      + +
      + )} +
      + ) + )} +
    + )} +
    + )}
    ))} diff --git a/apps/web/lib/features/task/daily-plan/table-view/cells/task-action-menu-cell.tsx b/apps/web/lib/features/task/daily-plan/table-view/cells/task-action-menu-cell.tsx new file mode 100644 index 000000000..89643f9df --- /dev/null +++ b/apps/web/lib/features/task/daily-plan/table-view/cells/task-action-menu-cell.tsx @@ -0,0 +1,51 @@ +import { ITeamTask } from '@/app/interfaces'; +import { CellContext } from '@tanstack/react-table'; +import { ActiveTaskStatusDropdown } from '../../../task-status'; +import { useMemo, useState } from 'react'; +import { I_UserProfilePage, useOrganizationTeams, useTeamMemberCard } from '@/app/hooks'; +import { get } from 'lodash'; +import { TaskCardMenu } from '../../../task-card'; + +export default function TaskActionMenuCell(props: CellContext) { + const [loading, setLoading] = useState(false); + const { activeTeam } = useOrganizationTeams(); + const members = useMemo(() => activeTeam?.members || [], [activeTeam?.members]); + const profile = get(props.column, 'columnDef.meta.profile') as unknown as I_UserProfilePage; + const plan = get(props.column, 'columnDef.meta.plan'); + const planMode = get(props.column, 'columnDef.meta.planMode'); + const currentMember = useMemo( + () => + members.find((m) => { + return m.employee.user?.id === profile?.userProfile?.id; + }), + [members, profile?.userProfile?.id] + ); + const memberInfo = useTeamMemberCard(currentMember || undefined); + + return ( +
    + {/* Active Task Status Dropdown (It's a dropdown that allows the user to change the status of the task.)*/} +
    + setLoading(load)} + className="min-w-[10.625rem] text-sm" + /> +
    + {/* TaskCardMenu */} +
    + {props.row.original && currentMember && ( + + )} +
    +
    + ); +} diff --git a/apps/web/lib/features/task/daily-plan/table-view/cells/task-estimation-cell.tsx b/apps/web/lib/features/task/daily-plan/table-view/cells/task-estimation-cell.tsx new file mode 100644 index 000000000..51a933455 --- /dev/null +++ b/apps/web/lib/features/task/daily-plan/table-view/cells/task-estimation-cell.tsx @@ -0,0 +1,24 @@ +import { I_UserProfilePage, useOrganizationTeams, useTeamMemberCard, useTMCardTaskEdit } from '@/app/hooks'; +import { ITeamTask } from '@/app/interfaces'; +import { TaskEstimateInfo } from '@/lib/features/team/user-team-card/task-estimate'; +import { CellContext } from '@tanstack/react-table'; +import { get } from 'lodash'; +import { useMemo } from 'react'; + +export default function DailyPlanTaskEstimationCell(props: CellContext) { + const plan = get(props.column, 'columnDef.meta.plan'); + const profile = get(props.column, 'columnDef.meta.profile') as unknown as I_UserProfilePage; + const { activeTeam } = useOrganizationTeams(); + const members = useMemo(() => activeTeam?.members || [], [activeTeam?.members]); + const currentMember = useMemo( + () => + members.find((m) => { + return m.employee.user?.id === profile?.userProfile?.id; + }), + [members, profile?.userProfile?.id] + ); + const memberInfo = useTeamMemberCard(currentMember || undefined); + const taskEdition = useTMCardTaskEdit(props.row.original); + + return ; +} diff --git a/apps/web/lib/features/task/daily-plan/table-view/cells/task-info-cell.tsx b/apps/web/lib/features/task/daily-plan/table-view/cells/task-info-cell.tsx new file mode 100644 index 000000000..d31827ce7 --- /dev/null +++ b/apps/web/lib/features/task/daily-plan/table-view/cells/task-info-cell.tsx @@ -0,0 +1,7 @@ +import { CellContext } from '@tanstack/react-table'; +import { TaskInfo } from '../../../task-card'; +import { ITeamTask } from '@/app/interfaces'; + +export default function DailyPlanTaskInfoCell(props: CellContext) { + return ; +} diff --git a/apps/web/lib/features/task/daily-plan/table-view/cells/task-times-cell.tsx b/apps/web/lib/features/task/daily-plan/table-view/cells/task-times-cell.tsx new file mode 100644 index 000000000..2ba60368f --- /dev/null +++ b/apps/web/lib/features/task/daily-plan/table-view/cells/task-times-cell.tsx @@ -0,0 +1,31 @@ +import { CellContext } from '@tanstack/react-table'; +import { TaskTimes } from '../../../task-times'; +import { ITeamTask } from '@/app/interfaces'; +import { I_UserProfilePage, useOrganizationTeams, useTeamMemberCard } from '@/app/hooks'; +import get from 'lodash/get'; +import { useMemo } from 'react'; + +export default function DailyPlanTaskTimesCell(props: CellContext) { + const profile = get(props.column, 'columnDef.meta.profile') as unknown as I_UserProfilePage; + const { activeTeam } = useOrganizationTeams(); + const members = useMemo(() => activeTeam?.members || [], [activeTeam?.members]); + const currentMember = useMemo( + () => + members.find((m) => { + return m.employee.user?.id === profile?.userProfile?.id; + }), + [members, profile?.userProfile?.id] + ); + const memberInfo = useTeamMemberCard(currentMember || undefined); + + return ( + + ); +} diff --git a/apps/web/lib/features/task/daily-plan/table-view/index.tsx b/apps/web/lib/features/task/daily-plan/table-view/index.tsx new file mode 100644 index 000000000..7adc2dc79 --- /dev/null +++ b/apps/web/lib/features/task/daily-plan/table-view/index.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import DataTable from '@components/ui/data-table'; +import { ColumnDef } from '@tanstack/react-table'; +import { IDailyPlan, ITeamTask } from '@app/interfaces'; +import DailyPlanTaskEstimationCell from './cells/task-estimation-cell'; +import DailyPlanTaskInfoCell from './cells/task-info-cell'; +import DailyPlanTaskTimesCell from './cells/task-times-cell'; +import TaskActionMenuCell from './cells/task-action-menu-cell'; +import { FilterTabs, I_UserProfilePage } from '@/app/hooks'; + +interface IDailyPlanTasksTableViewProps { + data: ITeamTask[]; + plan: IDailyPlan; + profile: I_UserProfilePage; + planMode?: FilterTabs; +} + +/** + * Table view of daily plan tasks + * + * @param {Object} props - THe props object + * @param {ITeamTask[]} props.data - The tasks + * @param {I_UserProfilePage} props.profile - The user profile page + * @param {FilterTabs} props.planMode - The plan mode to display + * + * @returns {JSX.Element} - The table view of daily plan tasks + */ +export default function DailyPlanTasksTableView(props: IDailyPlanTasksTableViewProps) { + const { data, plan, profile, planMode } = props; + + const columns = React.useMemo[]>( + () => [ + { + id: 'task', + header: 'Task', + tooltip: '', + cell: DailyPlanTaskInfoCell + }, + { + id: 'estimate', + header: 'Estimate', + tooltip: '', + cell: DailyPlanTaskEstimationCell, + meta: { + plan, + profile + } + }, + { + id: 'workedOn', + header: 'Worked On', + tooltip: '', + cell: DailyPlanTaskTimesCell, + meta: { + profile + } + }, + { + id: 'action', + header: 'Action', + tooltip: '', + cell: TaskActionMenuCell, + meta: { + plan, + profile, + planMode + } + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + return ; +} diff --git a/apps/web/lib/features/task/task-card.tsx b/apps/web/lib/features/task/task-card.tsx index ae4d67e6f..3eae62ec9 100644 --- a/apps/web/lib/features/task/task-card.tsx +++ b/apps/web/lib/features/task/task-card.tsx @@ -480,7 +480,7 @@ function TimerButtonCall({ //* Task Estimate info * //* Task Info FC * -function TaskInfo({ +export function TaskInfo({ className, task, taskBadgeClassName, @@ -526,7 +526,7 @@ function TaskInfo({ /** * It's a dropdown menu that allows the user to remove the task. */ -function TaskCardMenu({ +export function TaskCardMenu({ task, loading, memberInfo, diff --git a/apps/web/lib/features/team-members-table-view.tsx b/apps/web/lib/features/team-members-table-view.tsx index af2d3d471..50df9227d 100644 --- a/apps/web/lib/features/team-members-table-view.tsx +++ b/apps/web/lib/features/team-members-table-view.tsx @@ -72,7 +72,6 @@ const TeamMembersTableView = ({ return ( <> - []} diff --git a/apps/web/lib/features/user-profile-plans.tsx b/apps/web/lib/features/user-profile-plans.tsx index fbdac86f3..754622dd6 100644 --- a/apps/web/lib/features/user-profile-plans.tsx +++ b/apps/web/lib/features/user-profile-plans.tsx @@ -50,6 +50,7 @@ import TaskBlockCard from './task/task-block-card'; import { TaskCard } from './task/task-card'; import moment from 'moment'; import { usePathname } from 'next/navigation'; +import DailyPlanTasksTableView from './task/daily-plan/table-view'; export type FilterTabs = 'Today Tasks' | 'Future Tasks' | 'Past Tasks' | 'All Tasks' | 'Outstanding'; type FilterOutstanding = 'ALL' | 'DATE'; @@ -333,79 +334,95 @@ function AllPlans({ profile, currentTab = 'All Tasks' }: { profile: any; current - - {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( -
      - {plan.tasks?.map((task, index) => - view === 'CARDS' ? ( - - {(provided) => ( -
      - -
      - )} -
      - ) : ( - - {(provided) => ( -
      - -
      - )} -
      - ) - )} - <>{provided.placeholder} -
    - )} -
    + + {view === 'TABLE' ? ( + + ) : ( + + {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( +
      + {plan.tasks?.map((task, index) => + view === 'CARDS' ? ( + + {(provided) => ( +
      + +
      + )} +
      + ) : ( + + {(provided) => ( +
      + +
      + )} +
      + ) + )} + <>{provided.placeholder} +
    + )} +
    + )}
    ))} From 5b1a6bd28db8c0da67f27969b06cb0b421c16e4e Mon Sep 17 00:00:00 2001 From: "Thierry CH." Date: Mon, 18 Nov 2024 21:25:11 +0200 Subject: [PATCH 5/6] improvement: status type | edit/create mode (#3185) * improvement: status type | edit/create mode * fix svg icon colors * fix svg icon default value * adapt requested changes * add requested changes * fix build errors --- .vscode/settings.json | 4 +- apps/web/app/constants.ts | 2 + apps/web/app/services/server/fetch.ts | 39 ++++++++- apps/web/lib/features/task/task-status.tsx | 9 +- apps/web/lib/settings/icon-items.tsx | 9 +- apps/web/lib/settings/list-card.tsx | 56 +++++++++--- apps/web/lib/settings/task-statuses-form.tsx | 91 ++++++++++++++++---- apps/web/locales/ar.json | 2 + apps/web/locales/bg.json | 2 + apps/web/locales/de.json | 2 + apps/web/locales/en.json | 2 + apps/web/locales/es.json | 2 + apps/web/locales/fr.json | 2 + apps/web/locales/he.json | 2 + apps/web/locales/it.json | 2 + apps/web/locales/nl.json | 2 + apps/web/locales/pl.json | 2 + apps/web/locales/pt.json | 2 + apps/web/locales/ru.json | 2 + apps/web/locales/zh.json | 2 + 20 files changed, 196 insertions(+), 40 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 06dae99cd..be7e65f57 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,9 +23,7 @@ }, "vsicons.presets.angular": true, "deepscan.enable": true, - "cSpell.words": [ - "Timepicker" - ], + "cSpell.words": ["Timepicker"], "files.exclude": { "**/.git": true, "**/.DS_Store": true, diff --git a/apps/web/app/constants.ts b/apps/web/app/constants.ts index e2a2e0564..b7d0ad636 100644 --- a/apps/web/app/constants.ts +++ b/apps/web/app/constants.ts @@ -209,6 +209,8 @@ export const TaskStatus = { INPROGRESS: 'in-progress' }; +export const tasksStatusSvgCacheDuration = 1000 * 60 * 60; + export const languagesFlags = [ { Flag: US, diff --git a/apps/web/app/services/server/fetch.ts b/apps/web/app/services/server/fetch.ts index 32d56cb05..f026969ed 100644 --- a/apps/web/app/services/server/fetch.ts +++ b/apps/web/app/services/server/fetch.ts @@ -1,4 +1,4 @@ -import { GAUZY_API_SERVER_URL } from '@app/constants'; +import { GAUZY_API_SERVER_URL, tasksStatusSvgCacheDuration } from '@app/constants'; export function serverFetch({ path, @@ -54,3 +54,40 @@ export function serverFetch({ }; }); } + +/** Tasks status SVG icons fetch */ + +// In memory cache for performance + +const tasksStatusSvgCache = new Map< + string, + { + content: Response; + timestamp: number; + } +>(); + +export async function svgFetch(url: string): Promise { + try { + //Url validation + new URL(url); + + const cached = tasksStatusSvgCache.get(url); + const now = Date.now(); + + if (cached && now - cached.timestamp < tasksStatusSvgCacheDuration) { + return cached.content.clone(); + } + + // Fetch the SVG + const response = await fetch(url); + + tasksStatusSvgCache.set(url, { + content: response.clone(), + timestamp: now + }); + return response; + } catch { + throw new Error('Invalid URL provided'); + } +} diff --git a/apps/web/lib/features/task/task-status.tsx b/apps/web/lib/features/task/task-status.tsx index 73096654c..eb9c4e2d7 100644 --- a/apps/web/lib/features/task/task-status.tsx +++ b/apps/web/lib/features/task/task-status.tsx @@ -33,6 +33,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import { readableColor } from 'polished'; import { useTheme } from 'next-themes'; import { Square4OutlineIcon, CircleIcon } from 'assets/svg'; +import { getTextColor } from '@app/helpers'; import { cn } from '@/lib/utils'; export type TStatusItem = { @@ -854,7 +855,8 @@ export function TaskStatus({ className )} style={{ - backgroundColor: active ? backgroundColor : undefined + backgroundColor: active ? backgroundColor : undefined, + color: getTextColor(backgroundColor ?? 'white') }} >
    ({ className={clsxm( `justify-between capitalize`, sidebarUI && ['text-xs'], - !value && ['text-dark dark:text-white dark:bg-dark--theme-light'], + !value && ['!text-dark/40 dark:text-white'], isVersion || (forDetails && !value) ? 'bg-transparent border border-solid border-color-[#F2F2F2]' - : 'bg-[#F2F2F2] ', + : 'bg-white border', 'dark:bg-[#1B1D22] dark:border dark:border-[#FFFFFF33]', taskStatusClassName, isVersion && 'dark:text-white', @@ -1028,6 +1030,7 @@ export function StatusDropdown({ 10} label={capitalize(value?.name) || ''} + className="h-full" > {button} diff --git a/apps/web/lib/settings/icon-items.tsx b/apps/web/lib/settings/icon-items.tsx index 5ba87b2db..8c72290e2 100644 --- a/apps/web/lib/settings/icon-items.tsx +++ b/apps/web/lib/settings/icon-items.tsx @@ -2,6 +2,7 @@ import { GAUZY_API_BASE_SERVER_URL } from '@app/constants'; import { IIcon } from '@app/interfaces'; import { clsxm } from '@app/utils'; import { DropdownItem } from 'lib/components'; +import { useTranslations } from 'next-intl'; import Image from 'next/image'; export type IconItem = DropdownItem; @@ -53,6 +54,7 @@ export function IconItem({ url: string; disabled?: boolean; }) { + const t = useTranslations(); return (
    - {url && ( + {url ? (
    + ) : ( + {t('common.ICON')} )}
    - - {title} -
    ); } diff --git a/apps/web/lib/settings/list-card.tsx b/apps/web/lib/settings/list-card.tsx index f07ef63b0..e9ad47a24 100644 --- a/apps/web/lib/settings/list-card.tsx +++ b/apps/web/lib/settings/list-card.tsx @@ -1,11 +1,12 @@ import { EditPenUnderlineIcon, TrashIcon } from 'assets/svg'; import { Button, Text, Tooltip } from 'lib/components'; -import Image from 'next/image'; import { CHARACTER_LIMIT_TO_SHOW } from '@app/constants'; import { IClassName } from '@app/interfaces'; import { clsxm } from '@app/utils'; import { getTextColor } from '@app/helpers'; import { useTranslations } from 'next-intl'; +import { useEffect } from 'react'; +import { svgFetch } from '@app/services/server/fetch'; export const StatusesListCard = ({ statusIcon, @@ -27,6 +28,12 @@ export const StatusesListCard = ({ const textColor = getTextColor(bgColor); const t = useTranslations(); + useEffect(() => { + if (statusIcon) { + loadSVG(statusIcon, 'icon-container' + statusTitle, textColor); + } + }, [statusIcon, statusTitle, textColor]); + return (
    - {statusIcon && ( - {statusTitle} - )} + {statusIcon &&
    } = CHARACTER_LIMIT_TO_SHOW} @@ -84,3 +80,37 @@ export const StatusesListCard = ({
    ); }; + +/** + * A function to load an SVG and gives the ability to + * update its attributes. e.g: fill color + * + * @param {string} url the URL of the SVG file to load + * @param {string} containerId the ID of the container where the SVG will be inserted + * @param {string} color the fill color for the SVG + */ +const loadSVG = async (url: string, containerId: string, color: string): Promise => { + try { + const response = await svgFetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch SVG: ${response.statusText}`); + } + + let svgContent = await response.text(); + + // Update the fill color in the SVG content + svgContent = svgContent.replace(/stroke="[^"]*"/g, `stroke="${color}"`); + + const container = document.getElementById(containerId); + + if (container) { + console.log(container); + container.innerHTML = svgContent; + } else { + console.error(`Container with ID "${containerId}" not found.`); + } + } catch (error) { + console.error(`Error loading SVG: ${(error as Error).message}`); + } +}; diff --git a/apps/web/lib/settings/task-statuses-form.tsx b/apps/web/lib/settings/task-statuses-form.tsx index 678e9b569..c07e48b7b 100644 --- a/apps/web/lib/settings/task-statuses-form.tsx +++ b/apps/web/lib/settings/task-statuses-form.tsx @@ -6,7 +6,7 @@ import { clsxm } from '@app/utils'; import { Spinner } from '@components/ui/loaders/spinner'; import { PlusIcon } from '@heroicons/react/20/solid'; import { Button, ColorPicker, InputField, Modal, Text } from 'lib/components'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslations } from 'next-intl'; import { useAtom } from 'jotai'; @@ -31,6 +31,8 @@ export const TaskStatusesForm = ({ const [createNew, setCreateNew] = useState(formOnly); const [edit, setEdit] = useState(null); const t = useTranslations(); + const [selectedStatusType, setSelectedStatusType] = useState(null); + const [randomColor, setRandomColor] = useState(undefined); const taskStatusIconList: IIcon[] = generateIconList('task-statuses', [ 'open', @@ -38,14 +40,15 @@ export const TaskStatusesForm = ({ 'ready', 'in-review', 'blocked', - 'completed' + 'completed', + 'backlog', ]); const taskSizesIconList: IIcon[] = generateIconList('task-sizes', [ - 'x-large' - // 'large', - // 'medium', - // 'small', - // 'tiny', + 'x-large', + 'large', + 'medium', + 'small', + 'tiny', ]); const taskPrioritiesIconList: IIcon[] = generateIconList('task-priorities', [ 'urgent', @@ -54,11 +57,12 @@ export const TaskStatusesForm = ({ 'low' ]); - const iconList: IIcon[] = [ + const iconList: IIcon[] = useMemo(() => [ ...taskStatusIconList, ...taskSizesIconList, ...taskPrioritiesIconList - ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + ],[]) ; const { loading, @@ -157,6 +161,53 @@ export const TaskStatusesForm = ({ const [statusToDelete, setStatusToDelete] = useState(null) const {tasks} = useTeamTasks() + /** + * Get Icon by status name + * + * @param {string} iconName - Name of the icon + * @returns {IIcon} - Icon of the status + */ + const getIcon = useCallback( + (iconName: string | null) => { + if (!iconName) return null; + + const STATUS_MAPPINGS: Record = { + 'ready-for-review': 'ready' + }; + + const name = STATUS_MAPPINGS[iconName] || iconName; + + const icon = iconList.find((icon) => icon.title === name); + + if (icon) { + setValue('icon', icon.path); + } + return icon; + }, + [iconList, setValue] + ); + + + /** + * Get random color for new status + * + * @returns {string} - Random color + */ + const getRandomColor = useCallback(() => { + const letters = '0123456789ABCDEF'; + let color = '#'; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; + }, []); + + useEffect(() => { + if (!edit && selectedStatusType) { + setRandomColor(getRandomColor()); + } + }, [selectedStatusType, edit, getRandomColor]); + return ( <> @@ -195,7 +246,7 @@ export const TaskStatusesForm = ({ variant="outline" className="rounded-[10px]" > - Sort + {t('common.SORT')}
    {(createNew || edit) && ( @@ -218,22 +269,26 @@ export const TaskStatusesForm = ({ {...register('name')} /> setValue('template', status)} - className=" h-14 shrink-0" + onValueChange={(status) => { + setValue('template', status) + setSelectedStatusType(status) + } } + className="h-14 shrink-0" + defaultValue={edit?.value} /> icon.path === edit.icon - ) as IIcon) - : null + ) as IIcon) : null } /> setValue('color', color)} className=" shrink-0" /> @@ -247,6 +302,9 @@ export const TaskStatusesForm = ({ createTaskStatusLoading || editTaskStatusLoading } loading={createTaskStatusLoading || editTaskStatusLoading} + onClick={() => { + setSelectedStatusType(null); + }} > {edit ? t('common.SAVE') : t('common.CREATE')} @@ -257,6 +315,7 @@ export const TaskStatusesForm = ({ onClick={() => { setCreateNew(false); setEdit(null); + setSelectedStatusType(null); }} > {t('common.CANCEL')} diff --git a/apps/web/locales/ar.json b/apps/web/locales/ar.json index 77d575a46..2b45b804d 100644 --- a/apps/web/locales/ar.json +++ b/apps/web/locales/ar.json @@ -235,6 +235,8 @@ "CHANGE_RELATIONS": "تغيير العلاقات", "SET_AS_NEXT": "تعيين كالتالي", "MOVE_TO": "نقل إلى", + "SORT": "فرز", + "ICON": "أيقونة", "SELECT_DATE": "اختر التاريخ", "SELECT_AND_CLOSE": "اختر وأغلق", "SELECT_ROLE": "يرجى اختيار أي دور", diff --git a/apps/web/locales/bg.json b/apps/web/locales/bg.json index 146962a2e..6a35a5b89 100644 --- a/apps/web/locales/bg.json +++ b/apps/web/locales/bg.json @@ -255,6 +255,8 @@ "MOVE_TO": "Преместете в", "SELECT_DATE": "Изберете дата", "SELECT_AND_CLOSE": "Изберете и затворете", + "SORT": "Подредете", + "ICON": "Икона", "SELECT_ROLE": "Моля, изберете роля", "ADD_TIME": "Добавете време", "VIEW_TIMESHEET": "Преглед на работния час", diff --git a/apps/web/locales/de.json b/apps/web/locales/de.json index c42de6e56..a8cd98b43 100644 --- a/apps/web/locales/de.json +++ b/apps/web/locales/de.json @@ -255,6 +255,8 @@ "MOVE_TO": "Verschieben nach", "SELECT_DATE": "Datum auswählen", "SELECT_AND_CLOSE": "Auswählen und schließen", + "SORT": "Sortieren", + "ICON": "Symbol", "SELECT_ROLE": "Bitte wählen Sie eine Rolle", "ADD_TIME": "Zeit hinzufügen", "VIEW_TIMESHEET": "Zeiterfassung anzeigen", diff --git a/apps/web/locales/en.json b/apps/web/locales/en.json index a35d7ec92..e230c7a31 100644 --- a/apps/web/locales/en.json +++ b/apps/web/locales/en.json @@ -255,6 +255,8 @@ "MOVE_TO": "Move to", "SELECT_DATE": "Select date", "SELECT_AND_CLOSE": "Select & Close", + "SORT": "Sort", + "ICON": "Icon", "SELECT_ROLE": "Please Select any Role", "ADD_TIME": "Add Time", "VIEW_TIMESHEET": "View timesheet", diff --git a/apps/web/locales/es.json b/apps/web/locales/es.json index 8a30a3032..7e8997c94 100644 --- a/apps/web/locales/es.json +++ b/apps/web/locales/es.json @@ -255,6 +255,8 @@ "MOVE_TO": "Mover a", "SELECT_DATE": "Seleccionar fecha", "SELECT_AND_CLOSE": "Seleccionar y cerrar", + "SORT": "Ordenar", + "ICON": "Ícono", "SELECT_ROLE": "Por favor, selecciona un rol", "ADD_TIME": "Agregar tiempo", "VIEW_TIMESHEET": "Ver hoja de tiempo", diff --git a/apps/web/locales/fr.json b/apps/web/locales/fr.json index bbd5f0430..2c70c0917 100644 --- a/apps/web/locales/fr.json +++ b/apps/web/locales/fr.json @@ -255,6 +255,8 @@ "MOVE_TO": "Déplacer vers", "SELECT_DATE": "Sélectionner la date", "SELECT_AND_CLOSE": "Sélectionner et fermer", + "SORT": "Trier", + "ICON": "Icône", "SELECT_ROLE": "Veuillez sélectionner un rôle", "ADD_TIME": "Ajouter du temps", "VIEW_TIMESHEET": "Voir la feuille de temps", diff --git a/apps/web/locales/he.json b/apps/web/locales/he.json index 0cf6d5c9e..e6c81b8fc 100644 --- a/apps/web/locales/he.json +++ b/apps/web/locales/he.json @@ -255,6 +255,8 @@ "MOVE_TO": "העבר אל", "SELECT_DATE": "בחר תאריך", "SELECT_AND_CLOSE": "בחר וסגור", + "SORT": "מיין", + "ICON": "סמל", "SELECT_ROLE": "אנא בחר תפקיד", "ADD_TIME": "הוסף זמן", "VIEW_TIMESHEET": "הצג גיליון זמן", diff --git a/apps/web/locales/it.json b/apps/web/locales/it.json index 576c9af34..945980bd8 100644 --- a/apps/web/locales/it.json +++ b/apps/web/locales/it.json @@ -255,6 +255,8 @@ "MOVE_TO": "Sposta a", "SELECT_DATE": "Seleziona data", "SELECT_AND_CLOSE": "Seleziona e chiudi", + "SORT": "Ordina", + "ICON": "Icona", "SELECT_ROLE": "Seleziona un ruolo", "ADD_TIME": "Aggiungi tempo", "VIEW_TIMESHEET": "Vedi foglio ore", diff --git a/apps/web/locales/nl.json b/apps/web/locales/nl.json index 1c7612d5c..2445311c8 100644 --- a/apps/web/locales/nl.json +++ b/apps/web/locales/nl.json @@ -255,6 +255,8 @@ "MOVE_TO": "Verplaatsen naar", "SELECT_DATE": "Selecteer datum", "SELECT_AND_CLOSE": "Selecteren en sluiten", + "SORT": "Sorteren", + "ICON": "Icoon", "SELECT_ROLE": "Selecteer een rol", "ADD_TIME": "Tijd toevoegen", "VIEW_TIMESHEET": "Bekijk tijdregistratie", diff --git a/apps/web/locales/pl.json b/apps/web/locales/pl.json index 7e45f58ba..198f39004 100644 --- a/apps/web/locales/pl.json +++ b/apps/web/locales/pl.json @@ -255,6 +255,8 @@ "MOVE_TO": "Przenieś do", "SELECT_DATE": "Wybierz datę", "SELECT_AND_CLOSE": "Wybierz i zamknij", + "SORT": "Sortuj", + "ICON": "Ikona", "SELECT_ROLE": "Proszę wybrać rolę", "ADD_TIME": "Dodaj czas", "VIEW_TIMESHEET": "Zobacz Ewidencję czasu", diff --git a/apps/web/locales/pt.json b/apps/web/locales/pt.json index 4a832f9ab..033cc1a79 100644 --- a/apps/web/locales/pt.json +++ b/apps/web/locales/pt.json @@ -255,6 +255,8 @@ "MOVE_TO": "Mover para", "SELECT_DATE": "Selecionar data", "SELECT_AND_CLOSE": "Selecionar e fechar", + "SORT": "Classificar", + "ICON": "Ícone", "SELECT_ROLE": "Por favor, selecione um cargo", "ADD_TIME": "Adicionar tempo", "VIEW_TIMESHEET": "Ver folha de ponto", diff --git a/apps/web/locales/ru.json b/apps/web/locales/ru.json index 20c2605ab..3ab812538 100644 --- a/apps/web/locales/ru.json +++ b/apps/web/locales/ru.json @@ -255,6 +255,8 @@ "MOVE_TO": "Переместить в", "SELECT_DATE": "Выберите дату", "SELECT_AND_CLOSE": "Выбрать и закрыть", + "SORT": "Сортировать", + "ICON": "Иконка", "SELECT_ROLE": "Пожалуйста, выберите роль", "ADD_TIME": "Добавить время", "VIEW_TIMESHEET": "Просмотреть табель", diff --git a/apps/web/locales/zh.json b/apps/web/locales/zh.json index 3735c7f97..50d39d2d2 100644 --- a/apps/web/locales/zh.json +++ b/apps/web/locales/zh.json @@ -255,6 +255,8 @@ "MOVE_TO": "移动到", "SELECT_DATE": "选择日期", "SELECT_AND_CLOSE": "选择并关闭", + "SORT": "排序", + "ICON": "图标", "SELECT_ROLE": "请选择角色", "ADD_TIME": "添加时间", "VIEW_TIMESHEET": "查看工时表", From f89d96dc7ca20cbc39607bbdebceb83037af794c Mon Sep 17 00:00:00 2001 From: AKILIMAILI CIZUNGU Innocent <51681130+Innocent-Akim@users.noreply.github.com> Date: Mon, 18 Nov 2024 22:16:16 +0200 Subject: [PATCH 6/6] fix:bug (#3344) --- .cspell.json | 2 +- apps/web/app/interfaces/IDailyPlan.ts | 8 ++++---- apps/web/components/pages/kanban/menu-kanban-card.tsx | 5 ++--- .../features/daily-plan/create-daily-plan-form-modal.tsx | 8 ++++---- .../features/integrations/calendar/table-time-sheet.tsx | 4 ++-- apps/web/lib/features/task/task-card.tsx | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.cspell.json b/.cspell.json index be2df850e..9c47d01cc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -372,7 +372,7 @@ "tinvitations", "tnode", "Togger", - "tomorow", + "tomorrow", "Tongatapu", "tota", "TRANSFERT", diff --git a/apps/web/app/interfaces/IDailyPlan.ts b/apps/web/app/interfaces/IDailyPlan.ts index 8eba961df..e296af463 100644 --- a/apps/web/app/interfaces/IDailyPlan.ts +++ b/apps/web/app/interfaces/IDailyPlan.ts @@ -25,12 +25,12 @@ export interface ICreateDailyPlan extends IDailyPlanBase, IRelationnalEmployee, export interface IUpdateDailyPlan extends Partial, - Pick, - Partial> {} + Pick, + Partial> { } export interface IDailyPlanTasksUpdate extends Pick, - IBasePerTenantAndOrganizationEntity {} + IBasePerTenantAndOrganizationEntity { } export enum DailyPlanStatusEnum { OPEN = 'open', @@ -38,4 +38,4 @@ export enum DailyPlanStatusEnum { COMPLETED = 'completed' } -export type IDailyPlanMode = 'today' | 'tomorow' | 'custom'; +export type IDailyPlanMode = 'today' | 'tomorrow' | 'custom'; diff --git a/apps/web/components/pages/kanban/menu-kanban-card.tsx b/apps/web/components/pages/kanban/menu-kanban-card.tsx index 457433d6b..26ebbc833 100644 --- a/apps/web/components/pages/kanban/menu-kanban-card.tsx +++ b/apps/web/components/pages/kanban/menu-kanban-card.tsx @@ -170,7 +170,7 @@ export default function MenuKanbanCard({ item: task, member }: { item: ITeamTask
  • - +
  • @@ -227,8 +227,7 @@ function TeamMembersSelect(props: ITeamMemberSelectProps): JSX.Element { - `relative cursor-default select-none py-2 pl-10 pr-4 ${ - active ? 'bg-primary/5' : 'text-gray-900' + `relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-primary/5' : 'text-gray-900' }` } value={member} diff --git a/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx b/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx index 2baacd152..147d9e459 100644 --- a/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx +++ b/apps/web/lib/features/daily-plan/create-daily-plan-form-modal.tsx @@ -91,7 +91,7 @@ export function CreateDailyPlanFormModal({ date: planMode == 'today' ? toDay - : planMode == 'tomorow' + : planMode == 'tomorrow' ? tomorrowDate : new Date(moment(date).format('YYYY-MM-DD')), status: DailyPlanStatusEnum.OPEN, @@ -302,12 +302,12 @@ function MembersList({ {(member?.employee?.user?.image?.thumbUrl || member?.employee?.user?.image?.fullUrl || member?.employee?.user?.imageUrl) && - isValidUrl( - member?.employee?.user?.image?.thumbUrl || + isValidUrl( + member?.employee?.user?.image?.thumbUrl || member?.employee?.user?.image?.fullUrl || member?.employee?.user?.imageUrl || '' - ) ? ( + ) ? ( )} - {planMode === 'tomorow' && !taskPlannedForTomorrow && ( + {planMode === 'tomorrow' && !taskPlannedForTomorrow && ( {isPending || createDailyPlanLoading ? (