diff --git a/apps/web/app/[locale]/projects/[id]/page.tsx b/apps/web/app/[locale]/projects/[id]/page.tsx new file mode 100644 index 000000000..1c1b3484f --- /dev/null +++ b/apps/web/app/[locale]/projects/[id]/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { MainLayout } from '@/lib/layout'; +import { useOrganizationTeams } from '@/app/hooks'; +import { withAuthentication } from '@/lib/app/authenticator'; + +function Page() { + const { isTrackingEnabled } = useOrganizationTeams(); + return ( + +
+
+

Project

+
+
+ + } + >
+ ); +} + +export default withAuthentication(Page, { displayName: 'ProjectPage' }); diff --git a/apps/web/app/[locale]/projects/components/data-table.tsx b/apps/web/app/[locale]/projects/components/data-table.tsx new file mode 100644 index 000000000..6b8192e98 --- /dev/null +++ b/apps/web/app/[locale]/projects/components/data-table.tsx @@ -0,0 +1,301 @@ +'use client'; + +import * as React from 'react'; +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable +} from '@tanstack/react-table'; +import Image from 'next/image'; +import { Checkbox } from '@/components/ui/checkbox'; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useTranslations } from 'next-intl'; +import { IProject } from '@/app/interfaces'; +import { cn } from '@/lib/utils'; +import { useTaskStatus } from '@/app/hooks'; +import { useMemo } from 'react'; +import moment from 'moment'; +import { ArrowUpDown } from 'lucide-react'; +import { Button } from '@components/ui/button'; +import AvatarStack from '@components/shared/avatar-stack'; +import { SpinnerLoader } from '@/lib/components'; + +export type ProjectTableDataType = { + project: { + name: IProject['name']; + imageUrl: IProject['imageUrl']; + color: IProject['color']; + }; + status: IProject['status']; + startDate: IProject['startDate']; + endDate: IProject['endDate']; + members: IProject['members']; + managers: IProject['members']; + teams: IProject['teams']; +}; + +/** + * Renders a data table displaying projects. + * + * @component + * @param {Object} props - The component props. + * @param {ProjectTableDataType[]} props.data - Array of data objects projects information. + * @param {boolean} props.loading - Whether to show loading indicator when loading projects data. + * + * @returns {JSX.Element} A table showing projects information. + * + */ + +export function DataTableProject(props: { data: ProjectTableDataType[]; loading: boolean }) { + const { data, loading } = props; + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [rowSelection, setRowSelection] = React.useState({}); + const t = useTranslations(); + const { taskStatus } = useTaskStatus(); + + const statusColorsMap: Map = useMemo(() => { + return new Map(taskStatus.map((status) => [status.name, status.color])); + }, [taskStatus]); + + const columns: ColumnDef[] = [ + { + id: 'select', + header: ({ table }) => ( +
+ table.toggleAllPageRowsSelected(!!value)} + /> +
+ ), + cell: ({ row }) => ( +
+ row.toggleSelected(!!value)} /> +
+ ), + enableSorting: false, + enableHiding: false + }, + { + accessorKey: 'project', + header: ({ column }) => { + return ( + + ); + }, + cell: function ({ row }) { + return ( +
+
+
+ {!row.original?.project?.imageUrl ? ( + row.original?.project?.name?.substring(0, 2) + ) : ( + {row.original?.project?.name + )} +
+

{row.original?.project?.name}

+
+
+ ); + } + }, + { + accessorKey: 'status', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( +
+
+ {row.original?.status} +
+
+ ); + } + }, + { + accessorKey: 'startDate', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
+ {row.original?.startDate && moment(row.original?.startDate).format('MMM. DD YYYY')} +
+ ) + }, + { + accessorKey: 'endDate', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => ( +
{row.original?.endDate && moment(row.original?.endDate).format('MMM. DD YYYY')}
+ ) + }, + { + accessorKey: 'members', + header: () =>
{t('common.MEMBERS')}
, + cell: ({ row }) => { + const members = + row.original?.members + ?.filter((el) => !el.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + name: el?.employee?.fullName + })) || []; + + return members?.length > 0 ? : null; + } + }, + { + accessorKey: 'teams', + header: () =>
{t('common.TEAMS')}
, + cell: ({ row }) => { + const teams = + row.original?.teams?.map((el) => ({ + name: el?.name + })) || []; + + return teams?.length > 0 ? : null; + } + }, + { + accessorKey: 'managers', + header: () =>
{t('common.MANAGERS')}
, + cell: ({ row }) => { + const managers = + row.original?.managers + ?.filter((el) => el.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + name: el?.employee?.fullName + })) || []; + + return managers?.length > 0 ? : null; + } + } + ]; + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection + } + }); + + React.useEffect(() => { + console.log(loading); + }, [loading]); + + return ( +
+ {loading ? ( +
+ +
+ ) : table?.getRowModel()?.rows.length ? ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table?.getRowModel()?.rows.length ? ( + table?.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {t('common.NO_RESULT')} + + + )} + +
+
+ ) : ( +
+ {t('common.NO_RESULT')} +
+ )} +
+ ); +} diff --git a/apps/web/app/[locale]/projects/components/filters-card-modal.tsx b/apps/web/app/[locale]/projects/components/filters-card-modal.tsx new file mode 100644 index 000000000..eb6d6d8d4 --- /dev/null +++ b/apps/web/app/[locale]/projects/components/filters-card-modal.tsx @@ -0,0 +1,410 @@ +import { Card, Modal } from '@/lib/components'; +import { ListFilterPlus, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { MultiSelectWithSearch } from './multi-select-with-search'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button } from '@components/ui/button'; +import { useOrganizationProjects, useOrganizationTeams, useTaskStatus } from '@/app/hooks'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { OrganizationProjectBudgetTypeEnum } from '@/app/interfaces'; + +interface IFiltersCardModalProps { + open: boolean; + closeModal: () => void; +} + +export default function FiltersCardModal(props: IFiltersCardModalProps) { + const { open, closeModal } = props; + const t = useTranslations(); + const [selectedTeams, setSelectedTeams] = useState([]); + const [selectedMembers, setSelectedMembers] = useState([]); + const [selectedManagers, setSelectedManagers] = useState([]); + const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedBudgetType, setSelectedBudgetType] = useState([]); + const params = useSearchParams(); + const { teams } = useOrganizationTeams(); + const { organizationProjects } = useOrganizationProjects(); + const teamMembers = useMemo( + () => organizationProjects.flatMap((project) => project.members ?? []), + [organizationProjects] + ); + const budgetTypes: { + value: string; + id: OrganizationProjectBudgetTypeEnum; + }[] = useMemo( + () => [ + { + value: t('common.COST_BASED'), + id: OrganizationProjectBudgetTypeEnum.COST + }, + { + value: t('common.HOURS_BASED'), + id: OrganizationProjectBudgetTypeEnum.HOURS + } + ], + [t] + ); + const { taskStatus } = useTaskStatus(); + const router = useRouter(); + const statusColorsMap: Map = useMemo(() => { + return new Map(taskStatus.map((status) => [status.name, status.color])); + }, [taskStatus]); + + const members = useMemo( + () => + teamMembers + ?.filter((el) => !el?.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + value: el?.employee?.fullName, + id: el?.employeeId + })) || [], + [teamMembers] + ); + + const managers = useMemo( + () => + teamMembers + ?.filter((el) => el?.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + value: el?.employee?.fullName, + id: el?.employeeId + })) || [], + [teamMembers] + ); + + const handleApplyFilters = useCallback(() => { + const searchParams = new URLSearchParams(window.location.search); + + const updateQueryParam = (key: string, values: string[]) => { + if (values.length > 0) { + searchParams.set(key, values.join(',')); + } else { + searchParams.delete(key); + } + }; + + updateQueryParam('teams', selectedTeams); + updateQueryParam('members', selectedMembers); + updateQueryParam('managers', selectedManagers); + updateQueryParam('status', selectedStatus); + updateQueryParam('budgetTypes', selectedBudgetType); + closeModal(); + + router.replace(`?${searchParams.toString()}`, { scroll: false }); + }, [selectedTeams, selectedMembers, selectedManagers, selectedStatus, selectedBudgetType, closeModal, router]); + + const handleClearAllFilters = useCallback(() => { + setSelectedTeams([]); + setSelectedMembers([]); + setSelectedManagers([]); + setSelectedStatus([]); + setSelectedBudgetType([]); + handleApplyFilters(); + closeModal(); + }, [closeModal, handleApplyFilters]); + + const getSelectedQueries = useCallback(() => { + setSelectedTeams(params.get('teams')?.split(',') || []); + setSelectedMembers(params.get('members')?.split(',') || []); + setSelectedManagers(params.get('managers')?.split(',') || []); + setSelectedStatus(params.get('status')?.split(',') || []); + setSelectedBudgetType(params.get('budgetTypes')?.split(',') || []); + }, [params]); + + useEffect(() => { + getSelectedQueries(); + }, [getSelectedQueries]); + + return ( + + +
+ {t('common.FILTER')} +
+
+
+
+
+ {t('common.TEAM')} + {selectedTeams.length > 0 && ( + + )} +
+ { + const name = teams.find((team) => team.id === teamId)?.name ?? '-'; + + return { + value: name, + id: teamId + }; + })} + onChange={(data) => setSelectedTeams(data.map((team) => team.id))} + options={teams.map((team) => ({ + value: team.name, + id: team.id + }))} + placeholder="Select a team..." + /> +
+ {selectedTeams.map((teamId) => ( +
+ + {teams.find((team) => team.id === teamId)?.name ?? '-'} + + +
+ ))} +
+
+
+
+ {t('common.STATUS')} + {selectedStatus.length > 0 && ( + + )} +
+ { + const name = taskStatus.find((status) => status.id === statusId)?.name ?? '-'; + + return { + value: name, + id: statusId + }; + })} + onChange={(data) => setSelectedStatus(data.map((status) => status.id))} + options={taskStatus + ?.filter((el) => el.name) + ?.map((status) => ({ id: status.id, value: status.name! }))} + placeholder="Select a status..." + /> +
+ {selectedStatus.map((statusId) => ( +
el.id == statusId)?.name + ) + }} + className=" rounded-md flex items-center gap-1 bg-gray-200 py-[.125rem] dark:text-black px-2" + key={statusId} + > + + {taskStatus.find((el) => el.id == statusId)?.name} + + +
+ ))} +
+
+
+
+ {t('common.MANAGER')} + {selectedManagers.length > 0 && ( + + )} +
+ { + const name = managers.find((manager) => manager.id === managerId)?.value ?? '-'; + + return { + value: name, + id: managerId + }; + })} + onChange={(data) => setSelectedManagers(data.map((manager) => manager.id))} + options={managers} + placeholder="Select a manager..." + /> +
+ {selectedManagers.map((managerId) => { + const manager = managers.find((manager) => manager.id === managerId); + + return manager ? ( +
+ {manager.imageUrl ? ( + {manager.value} + ) : ( +
{manager.value.substring(0, 2)}
+ )} + +
+ {manager.value} + + +
+
+ ) : null; + })} +
+
+
+
+ {t('common.MEMBER')} + {selectedMembers.length > 0 && ( + + )} +
+ { + const name = members.find((member) => member.id === memberId)?.value ?? '-'; + + return { + value: name, + id: memberId + }; + })} + onChange={(data) => setSelectedMembers(data.map((member) => member.id))} + options={members} + searchEnabled + placeholder="Select a member..." + /> +
+ {selectedMembers.map((memberId) => { + const member = members.find((member) => member.id === memberId); + + return member ? ( +
+ {member.imageUrl ? ( + {member.value} + ) : ( +
{member.value.substring(0, 2)}
+ )} +
+ {member.value} + + +
+
+ ) : null; + })} +
+
+
+
+ {t('common.BUDGET_TYPE')} + {selectedBudgetType.length > 0 && ( + + )} +
+ { + const name = budgetTypes.find((budget) => budget.id === budgetTypeId)?.value ?? '-'; + + return { + value: name, + id: budgetTypeId + }; + })} + onChange={(data) => setSelectedBudgetType(data.map((budgetType) => budgetType.id))} + options={budgetTypes} + placeholder="Select a budget type..." + /> +
+ {selectedBudgetType.map((typeId) => { + const type = budgetTypes.find((budget) => budget.id === typeId); + + return type ? ( +
+ {type.value}{' '} + +
+ ) : null; + })} +
+
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/projects/components/grid-item.tsx b/apps/web/app/[locale]/projects/components/grid-item.tsx new file mode 100644 index 000000000..0f16d6a13 --- /dev/null +++ b/apps/web/app/[locale]/projects/components/grid-item.tsx @@ -0,0 +1,149 @@ +import { cn } from '@/lib/utils'; +import { Checkbox } from '@components/ui/checkbox'; +import Image from 'next/image'; +import { ProjectTableDataType } from './data-table'; +import { CalendarDays, Ellipsis } from 'lucide-react'; +import { useTaskStatus } from '@/app/hooks'; +import { useMemo } from 'react'; +import moment from 'moment'; +import AvatarStack from '@components/shared/avatar-stack'; +import { useTranslations } from 'next-intl'; + +interface IGridItemProps { + data: ProjectTableDataType; +} + +export default function GridItem(props: IGridItemProps) { + const { data } = props; + + const { taskStatus } = useTaskStatus(); + + const statusColorsMap: Map = useMemo(() => { + return new Map(taskStatus.map((status) => [status.name, status.color])); + }, [taskStatus]); + + const members = useMemo( + () => + data?.members + ?.filter((el) => !el.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + name: el?.employee?.fullName + })) || [], + [data?.members] + ); + + const managers = useMemo( + () => + data?.members + ?.filter((el) => el.isManager) + ?.map((el) => ({ + imageUrl: el?.employee?.user?.imageUrl, + name: el?.employee?.fullName + })) || [], + [data?.members] + ); + + const teams = useMemo( + () => + data?.teams?.map((el) => ({ + name: el?.name + })) || [], + [data?.teams] + ); + + const t = useTranslations(); + + return ( +
+ +
+
+
+
+ {!data?.project?.imageUrl ? ( + data?.project?.name?.substring(0, 2) + ) : ( + {data?.project?.name + )} +
+

{data?.project?.name}

+
+ +
+ +
+

{t('common.STATUS')}

+ {data?.status ? ( +
+ {data?.status} +
+ ) : ( + '-' + )} +
+ +
+
+

{t('common.START_DATE')}

+
+ {data?.startDate ? ( + <> + +

{moment(data?.startDate).format('D.MM.YYYY')}

+ + ) : ( + '-' + )} +
+
+ +
+

{t('common.END_DATE')}

+
+ {data?.endDate ? ( + <> + +

{moment(data?.endDate).format('D.MM.YYYY')}

+ + ) : ( + '-' + )} +
+
+
+ +
+
+

{t('common.MEMBERS')}

+
{members?.length > 0 ? : '-'}
+
+ +
+

{t('common.TEAMS')}

+
{teams?.length > 0 ? : '-'}
+
+ +
+

{t('common.MANAGERS')}

+
{managers?.length > 0 ? : '-'}
+
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/projects/components/multi-select-with-search.tsx b/apps/web/app/[locale]/projects/components/multi-select-with-search.tsx new file mode 100644 index 000000000..facfd00f8 --- /dev/null +++ b/apps/web/app/[locale]/projects/components/multi-select-with-search.tsx @@ -0,0 +1,108 @@ +import { cn } from '@/lib/utils'; +import { ScrollArea, ScrollBar } from '@components/ui/scroll-bar'; +import { Popover } from '@headlessui/react'; +import { Check, ChevronDown, Search } from 'lucide-react'; +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; + +interface IProps { + placeholder: string; + options: TItem[]; + selectedOptions: TItem[]; + defaultOptions?: TItem[]; + onChange: (selectedValues: TItem[]) => void; + searchEnabled?: boolean; +} + +export function MultiSelectWithSearch(props: IProps) { + const { placeholder, options, selectedOptions, defaultOptions, onChange, searchEnabled = false } = props; + + const [searchTerm, setSearchTerm] = useState(''); + + const handleSearchChange = useCallback((event: ChangeEvent) => { + setSearchTerm(event.target.value); + }, []); + + const handleSelect = useCallback( + (selectedOption: T) => { + const newSelectedOptions = selectedOptions; + + if (!selectedOptions.map((el) => el.value).includes(selectedOption.value)) { + newSelectedOptions.push(selectedOption); + } else { + newSelectedOptions.splice(newSelectedOptions.indexOf(selectedOption), 1); + } + onChange(newSelectedOptions); + }, + [onChange, selectedOptions] + ); + + useEffect(() => { + if (defaultOptions) { + onChange(defaultOptions); + } + }, [defaultOptions, onChange]); + + return ( + +
+ {searchEnabled && ( + + )} + {placeholder}
+ })} + > + + + +
    + + {options + ?.filter((option) => String(option.value).toLowerCase().includes(searchTerm.toLowerCase())) + ?.map((option) => { + const isSelected = selectedOptions.map((el) => el.value).includes(option.value); + + return ( +
  • handleSelect(option)} + key={option.id} + className={cn( + 'w-full cursor-pointer flex gap-2 px-4 py-2 rounded-sm text-sm', + isSelected ? 'bg-primary text-white' : 'hover:bg-primary/10' + )} + > + 0 ? 'block' : 'hidden', + isSelected ? ' opacity-100' : ' opacity-0' + )} + size={15} + /> + + {String(option.value)} + +
  • + ); + })} + + +
    +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/projects/components/page-component.tsx b/apps/web/app/[locale]/projects/components/page-component.tsx new file mode 100644 index 000000000..4f27b3c60 --- /dev/null +++ b/apps/web/app/[locale]/projects/components/page-component.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { MainLayout } from '@/lib/layout'; +import { useModal, useOrganizationProjects, useOrganizationTeams } from '@/app/hooks'; +import { withAuthentication } from '@/lib/app/authenticator'; +import { useEffect, useMemo, useState } from 'react'; +import { Grid, List, ListFilterPlus, Plus, Search, Settings2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button, InputField, Paginate, SpinnerLoader } from '@/lib/components'; +import { usePagination } from '@/app/hooks/features/usePagination'; +import { IProject } from '@/app/interfaces'; +import { ExportModeSelect } from '@components/shared/export-mode-select'; +import { DatePickerWithRange } from '@components/shared/date-range-select'; +import { DateRange } from 'react-day-picker'; +import { endOfMonth, startOfMonth } from 'date-fns'; +import GridItem from './grid-item'; +import { DataTableProject } from './data-table'; +import { LAST_SELECTED_PROJECTS_VIEW } from '@/app/constants'; +import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; +import FiltersCardModal from './filters-card-modal'; + +type TViewMode = 'GRID' | 'LIST'; + +function PageComponent() { + const t = useTranslations(); + const { + isOpen: isFiltersCardModalOpen, + closeModal: closeFiltersCardModal, + openModal: openFiltersCardModal + } = useModal(); + const { isTrackingEnabled } = useOrganizationTeams(); + const lastSelectedView = useMemo(() => { + try { + return localStorage.getItem(LAST_SELECTED_PROJECTS_VIEW) as TViewMode; + } catch (error) { + console.error('Failed to access localStorage:', error); + return null; + } + }, []); + const [selectedView, setSelectedView] = useState(lastSelectedView ?? 'LIST'); + const [projects, setProjects] = useState([]); + const { getOrganizationProjects, getOrganizationProjectsLoading } = useOrganizationProjects(); + const [dateRange] = useState({ + from: startOfMonth(new Date()), + to: endOfMonth(new Date()) + }); + const { activeTeam } = useOrganizationTeams(); + const activeTeamProjects = useMemo(() => activeTeam?.projects?.map((el) => el.id) ?? [], [activeTeam?.projects]); + const params = useSearchParams(); + const viewItems: { title: string; name: TViewMode; icon: any }[] = useMemo( + () => [ + { + title: t('pages.projects.views.LIST_VIEW'), + name: 'LIST', + icon: List + }, + { + title: t('pages.projects.views.GRID_VIEW'), + name: 'GRID', + icon: Grid + } + ], + [t] + ); + + const { total, onPageChange, itemsPerPage, itemOffset, endOffset, setItemsPerPage, currentItems } = + usePagination(projects ?? []); + + useEffect(() => { + const members = [...(params.get('managers')?.split(',') ?? []), ...(params.get('members')?.split(',') ?? [])]; + + const queries = { + ...(members.length > 0 && { + 'where[members][employeeId]': members[0] // Available but can work with one employee ID + }) + }; + + /* + TO DO: + - Filter by status + - Filter by budget type + - Filter by date range + - Filter by team + + when the api is ready + */ + + getOrganizationProjects({ queries }).then((data) => { + if (data && data?.items?.length > 0) { + setProjects(data.items); + } + }); + }, [getOrganizationProjects, params]); + + const filteredProjects = useMemo( + () => + currentItems + ?.filter((el) => activeTeamProjects.includes(el.id)) + ?.map((el) => ({ + project: { + name: el.name, + imageUrl: el.imageUrl, + color: el.color + }, + status: el.status, + startDate: el.startDate, + endDate: el.endDate, + members: el.members, + managers: el.members, + teams: el.teams + })), + [currentItems, activeTeamProjects] + ); + + return ( + +
+
+

{t('pages.projects.projectTitle.PLURAL')}

+
+
+
+
    + {viewItems.map((item, index) => ( +
  • { + setSelectedView(item.name); + localStorage.setItem(LAST_SELECTED_PROJECTS_VIEW, item.name); + }} + key={index} + className={cn( + 'w-[10rem] cursor-pointer gap-2 flex items-center justify-center', + selectedView == item.name ? ' text-primary' : ' text-slate-500' + )} + > + + {item.title} +
  • + ))} +
    +
+
+ +
+ +
+
+
+ + } + > +
+
+
+
+ {' '} + +
+
+ { + /* TODO: Implement date range handling */ + }} + className="bg-transparent dark:bg-transparent dark:border-white" + /> + + { + /* TODO: Implement export handling */ + }} + /> + +
+
+ {selectedView === 'LIST' ? ( +
+ +
+ +
+
+ ) : selectedView === 'GRID' ? ( +
+ {getOrganizationProjectsLoading ? ( +
+ +
+ ) : ( + filteredProjects.map((el) => ) + )} +
+ ) : null} +
+ +
+
+ ); +} + +export default withAuthentication(PageComponent, { displayName: 'ProjectsPage' }); diff --git a/apps/web/app/[locale]/projects/page.tsx b/apps/web/app/[locale]/projects/page.tsx new file mode 100644 index 000000000..44fce361b --- /dev/null +++ b/apps/web/app/[locale]/projects/page.tsx @@ -0,0 +1,5 @@ +import PageComponent from './components/page-component'; + +export default function Page() { + return ; +} diff --git a/apps/web/app/[locale]/reports/weekly-limit/page.tsx b/apps/web/app/[locale]/reports/weekly-limit/page.tsx index 14917570a..13aaf318d 100644 --- a/apps/web/app/[locale]/reports/weekly-limit/page.tsx +++ b/apps/web/app/[locale]/reports/weekly-limit/page.tsx @@ -5,10 +5,10 @@ import { withAuthentication } from '@/lib/app/authenticator'; import { Breadcrumb, Paginate } from '@/lib/components'; import { MainLayout } from '@/lib/layout'; import { useEffect, useMemo, useState } from 'react'; -import { DatePickerWithRange } from './components/date-range-select'; +import { DatePickerWithRange } from '../../../../components/shared/date-range-select'; import { MembersSelect } from './components/members-select'; import { GroupBySelect, TGroupByOption } from './components/group-by-select'; -import { ExportModeSelect } from './components/export-mode-select'; +import { ExportModeSelect } from '../../../../components/shared/export-mode-select'; import { getAccessTokenCookie, getOrganizationIdCookie, getTenantIdCookie } from '@/app/helpers'; import { useTimeLimits } from '@/app/hooks/features/useTimeLimits'; import { DateRange } from 'react-day-picker'; diff --git a/apps/web/app/constants.ts b/apps/web/app/constants.ts index c5a82b040..ed2e29928 100644 --- a/apps/web/app/constants.ts +++ b/apps/web/app/constants.ts @@ -297,6 +297,7 @@ export const DEFAULT_PLANNED_TASK_ID = 'default-planned-task-id'; export const LAST_OPTION__CREATE_DAILY_PLAN_MODAL = 'last-option--create-daily-plan-modal'; export const HAS_VISITED_OUTSTANDING_TASKS = 'has-visited-outstanding-tasks'; export const HAS_SEEN_DAILY_PLAN_SUGGESTION_MODAL = 'has-seen-daily-plan-suggestion-modal'; +export const LAST_SELECTED_PROJECTS_VIEW = 'last-selected-projects-view'; // OAuth provider's keys diff --git a/apps/web/app/hooks/features/useOrganizationProjects.ts b/apps/web/app/hooks/features/useOrganizationProjects.ts index fdf19e3b5..e7752a6dd 100644 --- a/apps/web/app/hooks/features/useOrganizationProjects.ts +++ b/apps/web/app/hooks/features/useOrganizationProjects.ts @@ -6,7 +6,7 @@ import { createOrganizationProjectAPI } from '@app/services/client/api'; import { userState } from '@app/stores'; -import { useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; import { useAtom } from 'jotai'; import { useQuery } from '../useQuery'; import { organizationProjectsState } from '@/app/stores/organization-projects'; @@ -64,15 +64,17 @@ export function useOrganizationProjects() { [getOrganizationProjectQueryCall] ); - const getOrganizationProjects = useCallback(async () => { - try { - const res = await getOrganizationProjectsQueryCall(); - - setOrganizationProjects(res.data.items); - } catch (error) { - console.log(error); - } - }, [getOrganizationProjectsQueryCall, setOrganizationProjects]); + const getOrganizationProjects = useCallback( + async ({ queries }: { queries?: Record } = {}) => { + try { + const res = await getOrganizationProjectsQueryCall({ queries }); + return res.data; + } catch (error) { + console.log(error); + } + }, + [getOrganizationProjectsQueryCall] + ); const createOrganizationProject = useCallback( async (data: { name: string }) => { @@ -92,6 +94,12 @@ export function useOrganizationProjects() { [createOrganizationProjectQueryCall, organizationProjects, setOrganizationProjects] ); + useEffect(() => { + getOrganizationProjects().then((data) => { + setOrganizationProjects(data?.items ?? []); + }); + }, [getOrganizationProjects, setOrganizationProjects]); + return { editOrganizationProjectSetting, editOrganizationProjectSettingLoading, diff --git a/apps/web/app/interfaces/IOrganizationTeam.ts b/apps/web/app/interfaces/IOrganizationTeam.ts index 42256c24f..675ba3e5d 100644 --- a/apps/web/app/interfaces/IOrganizationTeam.ts +++ b/apps/web/app/interfaces/IOrganizationTeam.ts @@ -101,6 +101,7 @@ export interface OT_Member { totalWorkedTasks: ITasksTimesheet[]; timerStatus?: ITimerStatusEnum; activeTaskId?: string; + isManager: boolean; } export type ITimerStatusEnum = 'running' | 'idle' | 'pause' | 'online' | 'suspended'; diff --git a/apps/web/app/interfaces/IProject.ts b/apps/web/app/interfaces/IProject.ts index 551260afc..9ac9b28e2 100644 --- a/apps/web/app/interfaces/IProject.ts +++ b/apps/web/app/interfaces/IProject.ts @@ -1,3 +1,9 @@ +import { IEmployee } from './IEmployee'; +import { IOrganizationTeam, OT_Member } from './IOrganizationTeam'; +import { ITeamTask } from './ITask'; +import { TaskStatusEnum } from './ITaskStatus'; +import { ITimeLog } from './timer/ITimerLogs'; + export interface IProjectRepository { id: string; createdAt?: string; @@ -14,20 +20,51 @@ export interface IProjectRepository { } export interface IProject { - id: string; - createdAt?: string; - updatedAt?: string; - tenantId: string; - organizationId: string; repositoryId?: number; repository?: IProjectRepository; + name?: string; + startDate?: Date; + endDate?: Date; + billing?: ProjectBillingEnum; + currency?: string; + members?: OT_Member[]; + public?: boolean; + owner?: ProjectOwnerEnum; + tasks?: ITeamTask[]; + teams?: IOrganizationTeam[]; + timeLogs?: ITimeLog[]; + code?: string; + description?: string; + color?: string; + billable?: boolean; + billingFlat?: boolean; + openSource?: boolean; + projectUrl?: string; + openSourceProjectUrl?: string; + budget?: number; + budgetType?: OrganizationProjectBudgetTypeEnum; + membersCount?: number; + imageUrl: string | null; + status?: TaskStatusEnum; + icon?: string; + archiveTasksIn?: number; + closeTasksIn?: number; + defaultAssigneeId?: string; + defaultAssignee?: IEmployee; isTasksAutoSync?: boolean; isTasksAutoSyncOnLabel?: boolean; syncTag?: string; - name?: string; - imageUrl: string | null; - membersCount?: number; - owner?: string; + organizationContactId?: string; + imageId?: string | null; + organizationId: string; + tenantId: string; + id: string; + readonly createdAt?: string; + readonly updatedAt?: string; + isActive?: boolean; + isArchived?: boolean; + archivedAt: string | null; + deletedAt: string | null; } export interface CustomFields { @@ -39,3 +76,19 @@ export interface IProjectCreate { organizationId: string; tenantId: string; } + +export enum ProjectBillingEnum { + RATE = 'RATE', + FLAT_FEE = 'FLAT_FEE', + MILESTONES = 'MILESTONES' +} + +export enum ProjectOwnerEnum { + CLIENT = 'CLIENT', + INTERNAL = 'INTERNAL' +} + +export enum OrganizationProjectBudgetTypeEnum { + HOURS = 'hours', + COST = 'cost' +} diff --git a/apps/web/app/services/client/api/organization-projects.ts b/apps/web/app/services/client/api/organization-projects.ts index 107225e44..2a550fecb 100644 --- a/apps/web/app/services/client/api/organization-projects.ts +++ b/apps/web/app/services/client/api/organization-projects.ts @@ -1,4 +1,4 @@ -import { IProject, PaginationResponse } from '@app/interfaces'; +import { IProject, PaginationResponse } from '@app/interfaces'; import { get, put } from '../axios'; import qs from 'qs'; import { getOrganizationIdCookie, getTenantIdCookie } from '@/app/helpers'; @@ -21,15 +21,29 @@ export function getOrganizationProjectAPI(id: string, tenantId?: string) { }); } -export function getOrganizationProjectsAPI() { - +export function getOrganizationProjectsAPI({ queries }: { queries?: Record } = {}) { const organizationId = getOrganizationIdCookie(); const tenantId = getTenantIdCookie(); const obj = { 'where[organizationId]': organizationId, 'where[tenantId]': tenantId, + 'join[alias]': 'organization_project', + 'join[leftJoin][tags]': 'organization_project.tags' + } as Record; + + const relations = ['members', 'teams', 'members.employee', 'members.employee.user']; + + relations.forEach((relation, i) => { + obj[`relations[${i}]`] = relation; + }); + + if (queries) { + Object.entries(queries).forEach(([key, value]) => { + obj[key] = value; + }); } + const query = qs.stringify(obj); return get>(`/organization-projects?${query}`, { diff --git a/apps/web/components/app-sidebar.tsx b/apps/web/components/app-sidebar.tsx index 64e9c2c84..3cd552265 100644 --- a/apps/web/components/app-sidebar.tsx +++ b/apps/web/components/app-sidebar.tsx @@ -8,8 +8,10 @@ import { X, Command, AudioWaveform, - GalleryVerticalEnd + GalleryVerticalEnd, + FolderKanban } from 'lucide-react'; +import Image from 'next/image'; import { NavMain } from '@/components/nav-main'; import { @@ -24,13 +26,16 @@ import { } from '@/components/ui/sidebar'; import Link from 'next/link'; import { cn } from '@/lib/utils'; -import { useAuthenticateUser, useModal, useOrganizationTeams } from '@/app/hooks'; +import { useAuthenticateUser, useModal, useOrganizationProjects, useOrganizationTeams } from '@/app/hooks'; import { useFavoritesTask } from '@/app/hooks/features/useFavoritesTask'; import { CreateTeamModal, TaskIssueStatus } from '@/lib/features'; import { useTranslations } from 'next-intl'; import { WorkspacesSwitcher } from './workspace-switcher'; import { SidebarOptInForm } from './sidebar-opt-in-form'; import { NavProjects } from './nav-projects'; +import { useActiveTeam } from '@/app/hooks/features/useActiveTeam'; +import { usePathname } from 'next/navigation'; +import { useMemo } from 'react'; type AppSidebarProps = React.ComponentProps & { publicTeam: boolean | undefined }; export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { const { user } = useAuthenticateUser(); @@ -40,6 +45,14 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { const { state } = useSidebar(); const { isOpen, closeModal } = useModal(); const t = useTranslations(); + const { activeTeam } = useActiveTeam(); + const pathname = usePathname(); + const { organizationProjects } = useOrganizationProjects(); + const projects = useMemo( + () => (pathname.split('/')[1] == 'all-teams' ? organizationProjects : activeTeam?.projects), + [activeTeam?.projects, organizationProjects, pathname] + ); + // This is sample data. const data = { workspaces: [ @@ -97,6 +110,7 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { { title: t('sidebar.DASHBOARD'), url: '#', + selectable: false, icon: LayoutDashboard, label: 'dashboard', items: [ @@ -115,6 +129,7 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { { title: t('sidebar.FAVORITES'), url: '#', + selectable: false, icon: Heart, label: 'favorites', items: @@ -178,6 +193,7 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { { title: t('sidebar.TASKS'), url: '#', + selectable: false, icon: Files, label: 'tasks', items: [ @@ -193,9 +209,53 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { } ] }, + { + title: t('sidebar.PROJECTS'), + url: '/projects', + selectable: true, + icon: FolderKanban, + label: 'projects', + items: [ + ...(projects + ? projects.map((project) => { + return { + title: project.name ?? '', + label: 'project', + url: `/projects/${project.id}`, + icon: ( +
+ {!project.imageUrl ? ( + project.name?.substring(0, 2) + ) : ( + {project.name + )} +
+ ) + }; + }) + : []), + { + title: 'Archived projects', + url: '', + label: 'Archived projects' + } + ] + }, { title: t('sidebar.MY_WORKS'), url: '#', + selectable: false, icon: MonitorSmartphone, label: 'my-work', items: [ @@ -217,6 +277,7 @@ export function AppSidebar({ publicTeam, ...props }: AppSidebarProps) { title: t('sidebar.REPORTS'), label: 'reports', url: '#', + selectable: false, icon: SquareActivity, items: [ { diff --git a/apps/web/components/nav-main.tsx b/apps/web/components/nav-main.tsx index 79934f941..7c19430d6 100644 --- a/apps/web/components/nav-main.tsx +++ b/apps/web/components/nav-main.tsx @@ -18,6 +18,7 @@ import { } from '@/components/ui/sidebar'; import Link from 'next/link'; import { activeMenuIndexState, activeSubMenuIndexState, openMenusState } from '@/app/stores/menu'; +import { ReactNode } from 'react'; export function NavMain({ items @@ -25,12 +26,14 @@ export function NavMain({ items: { title: string; url: string; + selectable: boolean; icon: LucideIcon; isActive?: boolean; items?: { title: string; url: string; component?: JSX.Element; + icon?: ReactNode; }[]; }[]; }>) { @@ -63,6 +66,41 @@ export function NavMain({ const handleSubMenuToggle = (subIndex: number) => { setActiveSubMenuIndex(subIndex); }; + + const ItemContent = (props: { + title: string; + url: string; + selectable: boolean; + icon: LucideIcon; + isActive?: boolean; + items?: { + title: string; + url: string; + component?: JSX.Element; + icon?: ReactNode; + }[]; + }) => { + return ( + <> + {state === 'collapsed' ? ( + + + + ) : ( + + )} + + + {props.title} + + + ); + }; return ( Platform @@ -91,24 +129,15 @@ export function NavMain({ asChild tooltip={item.title} > - - {state === 'collapsed' ? ( - - - - ) : ( - - )} - - - {item.title} + {item.selectable ? ( + + + + ) : ( + + - + )} ) : ( @@ -168,18 +197,29 @@ export function NavMain({ onClick={() => handleSubMenuToggle(key)} asChild > - - - {subItem.title} - - +
+ {subItem.icon && ( +
+ {subItem.icon} +
+ )} + + + {subItem.title} + + +
)} diff --git a/apps/web/components/nav-projects.tsx b/apps/web/components/nav-projects.tsx index bb0001da6..270f3e765 100644 --- a/apps/web/components/nav-projects.tsx +++ b/apps/web/components/nav-projects.tsx @@ -36,9 +36,9 @@ export function NavProjects({ const { isMobile } = useSidebar(); const { user } = useAuthenticateUser(); - const { userManagedTeams } = useOrganizationAndTeamManagers(); const t = useTranslations(); + return userManagedTeams && userManagedTeams.length > 0 ? ( {t('sidebar.PROJECTS')} @@ -93,7 +93,7 @@ export function NavProjects({