From 2a7113aca8bcadd7fb49e0d6cfaaaa9076bffc05 Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Wed, 5 Mar 2025 13:48:05 +0100 Subject: [PATCH 1/6] feat: update team creation dialog to include approvers and approval mode --- package.json | 1 + pnpm-lock.yaml | 34 ++++ src/core/types/enum.ts | 2 +- src/core/types/team.ts | 2 + src/modules/team/Routes.tsx | 2 - .../team/components/TeamCreateDialog.tsx | 161 ++++++++++++++++-- src/modules/team/pages/TeamCreatePage.tsx | 126 -------------- src/modules/team/pages/TeamsPage.tsx | 72 ++++---- 8 files changed, 218 insertions(+), 182 deletions(-) delete mode 100644 src/modules/team/pages/TeamCreatePage.tsx diff --git a/package.json b/package.json index cc699fc..dc3ae3f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-radio-group": "^1.2.3", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63a8084..7cb229a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.1 version: 1.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': + specifier: ^1.2.3 + version: 1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.1.1 version: 2.1.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -807,6 +810,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.2.3': + resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.1': resolution: {integrity: sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==} peerDependencies: @@ -2576,6 +2592,24 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.3.18)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-roving-focus@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.1 diff --git a/src/core/types/enum.ts b/src/core/types/enum.ts index e2cb6cf..5a3cb0d 100644 --- a/src/core/types/enum.ts +++ b/src/core/types/enum.ts @@ -13,7 +13,7 @@ export enum UserRole { } export enum UserRoleJson { - ORGANIZATION_ADMIN = "Admin", + ORGANIZATION_ADMIN = "Organization Admin", EMPLOYEE = "Employee", TEAM_ADMIN = "Team Admin" } diff --git a/src/core/types/team.ts b/src/core/types/team.ts index 9b3bd26..40d7528 100644 --- a/src/core/types/team.ts +++ b/src/core/types/team.ts @@ -6,6 +6,8 @@ export type TeamCompactResponse = { export type TeamCreateRequest = { name: string; metadata: Record; + teamApprovers?: string[]; + approvalMode?: "ALL" | "ANY"; } export type TeamResponse = { diff --git a/src/modules/team/Routes.tsx b/src/modules/team/Routes.tsx index 5a701ca..39513c6 100644 --- a/src/modules/team/Routes.tsx +++ b/src/modules/team/Routes.tsx @@ -1,14 +1,12 @@ import {Route} from "react-router-dom"; import AuthenticatedRoute from "@/modules/auth/components/AuthenticatedRoute.tsx"; import TeamsPage from "@/modules/team/pages/TeamsPage.tsx"; -import TeamCreatePage from "@/modules/team/pages/TeamCreatePage.tsx"; import DashboardLayout from "@/components/layout/DashboardLayout.tsx"; export default function TeamRoutes() { return ( }> }> - }> ); } \ No newline at end of file diff --git a/src/modules/team/components/TeamCreateDialog.tsx b/src/modules/team/components/TeamCreateDialog.tsx index 9d7a511..6faf40b 100644 --- a/src/modules/team/components/TeamCreateDialog.tsx +++ b/src/modules/team/components/TeamCreateDialog.tsx @@ -1,19 +1,31 @@ -import React from "react"; +import React, {useEffect, useState} from "react"; import {useForm} from "react-hook-form"; -import {TeamResponse} from "@/core/types/team.ts"; -import {Button} from "@/components/ui/button.tsx"; +import {Button} from "@/components/ui/button"; import {z} from "zod"; import {zodResolver} from "@hookform/resolvers/zod"; -import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from "@/components/ui/form.tsx"; -import {Input} from "@/components/ui/input.tsx"; -import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,} from "@/components/ui/dialog" +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; +import {Input} from "@/components/ui/input"; +import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"; import {Save, X} from "lucide-react"; +import {getUsers} from "@/core/services/userService"; +import {UserRole} from "@/core/types/enum"; +import {UserResponse} from "@/core/types/user.ts"; +import {toast} from "@/components/ui/use-toast.ts"; +import {getErrorMessage} from "@/core/utils/errorHandler.ts"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "@/components/ui/label"; const FormSchema = z.object({ name: z.string().min(2, { - message: "Team Name must be over 2 characters" + message: "Team name must be at least 2 characters long", }).max(20, { - message: "Team Name must be under 20 characters" + message: "Team name must be under 20 characters", + }), + teamApprovers: z.array(z.string()).min(1, { + message: "Please select at least one team approver.", + }), + approvalMode: z.enum(["ALL", "ANY"], { + required_error: "Please select an approval mode.", }), }); @@ -22,20 +34,57 @@ type CreateTeamInputs = z.infer; interface TeamCreateDialogProps { onClose: () => void; isOpen: boolean; - teamList: TeamResponse[]; - onSubmit: (name: string) => void; + onSubmit: ( + name: string, + teamApprovers: string[], + approvalMode: "ALL" | "ANY" + ) => void; } -export default function TeamCreateDialog({isOpen, onClose, onSubmit, teamList}: TeamCreateDialogProps) { +export default function TeamCreateDialog({isOpen, onClose, onSubmit}: TeamCreateDialogProps) { + const [organizationAdmins, setOrganizationAdmins] = useState([]); + const [isTeamAdminSelected, setIsTeamAdminSelected] = useState(false); + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: "", + teamApprovers: [], + approvalMode: "ALL", }, }); + useEffect(() => { + fetchUsers(); + }, []); + + const fetchUsers = async () => { + try { + const response = await getUsers(0, 100); + const admins = response.contents.filter(user => user.role === UserRole.ORGANIZATION_ADMIN); + setOrganizationAdmins(admins); + } catch (error) { + toast({ + title: "Error", + description: getErrorMessage(error as Error), + variant: "destructive", + }); + } + }; + + const handleTeamAdminToggle = () => { + setIsTeamAdminSelected(!isTeamAdminSelected); + const currentApprovers = form.getValues("teamApprovers"); + + if (isTeamAdminSelected) { + form.setValue("teamApprovers", currentApprovers.filter((item) => item !== "Team Admin")); + } else { + form.setValue("teamApprovers", [...currentApprovers, "Team Admin"]); + } + }; + const handleSubmit = (data: CreateTeamInputs) => { - onSubmit(data.name); + onSubmit(data.name, data.teamApprovers, data.approvalMode); onClose(); }; @@ -53,9 +102,91 @@ export default function TeamCreateDialog({isOpen, onClose, onSubmit, teamList}: name="name" render={({field}) => ( - Name + Team Name + + + + + + )} + /> + + ( + + Approval Mode + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> + + ( + + Team Approvers - +
+ {organizationAdmins.map((admin) => { + const isSelected = field.value.includes(`${admin.firstName} ${admin.lastName}`); + return ( + + ); + })} + +
@@ -69,7 +200,7 @@ export default function TeamCreateDialog({isOpen, onClose, onSubmit, teamList}: diff --git a/src/modules/team/pages/TeamCreatePage.tsx b/src/modules/team/pages/TeamCreatePage.tsx deleted file mode 100644 index 8f0d3a5..0000000 --- a/src/modules/team/pages/TeamCreatePage.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, {useEffect, useState} from "react"; -import {useForm} from "react-hook-form"; -import {useNavigate} from 'react-router-dom'; -import {toast} from "@/components/ui/use-toast.ts"; -import {getErrorMessage} from "@/core/utils/errorHandler.ts"; -import {TeamResponse} from "@/core/types/team.ts"; -import {createTeam, getTeams} from "@/core/services/teamService.ts"; -import {Button} from "@/components/ui/button.tsx"; -import {Alert, AlertDescription} from "@/components/ui/alert.tsx"; -import {Card} from "@/components/ui/card.tsx"; -import {z} from "zod"; -import {zodResolver} from "@hookform/resolvers/zod"; -import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from "@/components/ui/form.tsx"; -import {Input} from "@/components/ui/input.tsx"; -import PageContent from "@/components/layout/PageContent.tsx"; -import PageHeader from "@/components/layout/PageHeader.tsx"; - -const FormSchema = z.object({ - name: z.string().min(2, { - message: "team Name must be over 2 characters" - }).max(20, { - message: "team Name must be under 20 characters" - }), -}); - -type CreateTeamInputs = z.infer; - -export default function TeamCreatePage() { - const navigate = useNavigate(); - const [teamList, setTeamList] = useState([]); - const [errorMessage, setErrorMessage] = useState(""); - const [isProcessing, setIsProcessing] = useState(false); - - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - name: "", - }, - }); - - useEffect(() => { - getTeams() - .then((response: TeamResponse[]) => { - setTeamList(response); - }) - .catch((error) => { - const errorMessage = getErrorMessage(error); - toast({ - title: "Error", - description: errorMessage, - variant: "destructive", - }); - }); - }, []); - - const onSubmit = (data: CreateTeamInputs) => { - createOrganizationTeam(data); - }; - - const createOrganizationTeam = (data: CreateTeamInputs) => { - const exists = teamList.some(t => t.name === data.name); - - if (exists) { - setErrorMessage('A team already exists with this name.'); - return; - } - - const payload = { - name: data.name, - metadata: {}, - }; - setIsProcessing(true); - - createTeam(payload) - .then(() => { - setIsProcessing(false); - navigate('/teams'); - }) - .catch((error) => { - setIsProcessing(false); - const errorMessage = getErrorMessage(error?.message); - toast({ - title: "Error", - description: errorMessage, - variant: "destructive", - }); - }); - }; - - return ( - <> - {errorMessage && ( - - {errorMessage} - - )} - - - - - -
- - ( - - Name - - - - - - )} - /> - - - -
-
- - ); -} \ No newline at end of file diff --git a/src/modules/team/pages/TeamsPage.tsx b/src/modules/team/pages/TeamsPage.tsx index b730e8c..2d96374 100644 --- a/src/modules/team/pages/TeamsPage.tsx +++ b/src/modules/team/pages/TeamsPage.tsx @@ -1,14 +1,14 @@ -import React, {useEffect, useState} from 'react'; -import {toast} from "@/components/ui/use-toast.ts"; -import {getErrorMessage} from "@/core/utils/errorHandler.ts"; -import {TeamResponse} from "@/core/types/team.ts"; -import {createTeam, deleteTeam, getTeams, updateTeam} from "@/core/services/teamService.ts"; -import {Pencil, Plus, Trash} from "lucide-react"; -import {Button} from "@/components/ui/button.tsx"; -import {Card} from "@/components/ui/card.tsx"; -import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx"; +import React, { useEffect, useState } from 'react'; +import { toast } from "@/components/ui/use-toast.ts"; +import { getErrorMessage } from "@/core/utils/errorHandler.ts"; +import { TeamResponse } from "@/core/types/team.ts"; +import { createTeam, deleteTeam, getTeams, updateTeam } from "@/core/services/teamService.ts"; +import { Pencil, Plus, Trash } from "lucide-react"; +import { Button } from "@/components/ui/button.tsx"; +import { Card } from "@/components/ui/card.tsx"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table.tsx"; import TeamUpdateDialog from "@/modules/team/components/TeamUpdateDialog.tsx"; -import {DeleteModal} from "@/modules/team/components/TeamDeleteDialog.tsx"; +import { DeleteModal } from "@/modules/team/components/TeamDeleteDialog.tsx"; import PageContent from "@/components/layout/PageContent.tsx"; import PageHeader from "@/components/layout/PageHeader.tsx"; import TeamCreateDialog from "@/modules/team/components/TeamCreateDialog.tsx"; @@ -21,40 +21,38 @@ export default function TeamsPage() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); useEffect(() => { - const fetchTeams = async () => { - try { - const response = await getTeams(); - setTeamList(response); - } catch (error) { - const errorMessage = getErrorMessage(error as Error); - toast({ - title: "Error", - description: errorMessage, - variant: "destructive", - }); - } - }; - fetchTeams(); }, []); + const fetchTeams = async () => { + try { + const response = await getTeams(); + setTeamList(response); + } catch (error) { + toast({ + title: "Error", + description: getErrorMessage(error as Error), + variant: "destructive", + }); + } + }; + const handleRemoveTeam = async () => { if (!selectedTeamForDelete) return; setIsProcessing(true); try { await deleteTeam(selectedTeamForDelete.id); - setTeamList(teamList.filter((team) => team.id !== selectedTeamForDelete.id)); + setTeamList((prev) => prev.filter((team) => team.id !== selectedTeamForDelete.id)); toast({ title: "Success", - description: "team removed successfully!", + description: "Team removed successfully!", variant: "default", }); } catch (error) { - const errorMessage = getErrorMessage(error as Error); toast({ title: "Error", - description: errorMessage, + description: getErrorMessage(error as Error), variant: "destructive", }); } finally { @@ -76,14 +74,12 @@ export default function TeamsPage() { } }; - const createOrganizationTeam = async (name: string) => { + const handleCreateTeam = async (name: string, teamApprovers: string[], approvalMode: "ALL" | "ANY") => { try { setIsProcessing(true); - setIsCreateDialogOpen(false); // Check if the team name already exists - const exists = teamList.some((t) => t.name === name); - if (exists) { + if (teamList.some((t) => t.name === name)) { toast({ title: "Error", description: "A team with this name already exists.", @@ -93,8 +89,10 @@ export default function TeamsPage() { } await createTeam({ - name: name, + name, metadata: {}, + teamApprovers, + approvalMode }); toast({ @@ -104,9 +102,7 @@ export default function TeamsPage() { }); // Refresh team list after creation - const updatedTeams = await getTeams(); - setTeamList(updatedTeams); - + await fetchTeams(); } catch (error) { toast({ title: "Error", @@ -115,6 +111,7 @@ export default function TeamsPage() { }); } finally { setIsProcessing(false); + setIsCreateDialogOpen(false); } }; @@ -151,8 +148,7 @@ export default function TeamsPage() { setIsCreateDialogOpen(false)} - teamList={teamList} - onSubmit={(name) => createOrganizationTeam(name)} + onSubmit={handleCreateTeam} /> {selectedTeamForUpdate && ( From f063d84826da910622469c60fbbc33a9d6bd4509 Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Wed, 5 Mar 2025 14:51:43 +0100 Subject: [PATCH 2/6] feat: add team Approver and approval mode to table --- src/modules/team/pages/TeamsPage.tsx | 45 +++++++++++++++++++++------- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/src/modules/team/pages/TeamsPage.tsx b/src/modules/team/pages/TeamsPage.tsx index 2d96374..71fc9d4 100644 --- a/src/modules/team/pages/TeamsPage.tsx +++ b/src/modules/team/pages/TeamsPage.tsx @@ -1,17 +1,18 @@ -import React, { useEffect, useState } from 'react'; -import { toast } from "@/components/ui/use-toast.ts"; -import { getErrorMessage } from "@/core/utils/errorHandler.ts"; -import { TeamResponse } from "@/core/types/team.ts"; -import { createTeam, deleteTeam, getTeams, updateTeam } from "@/core/services/teamService.ts"; -import { Pencil, Plus, Trash } from "lucide-react"; -import { Button } from "@/components/ui/button.tsx"; -import { Card } from "@/components/ui/card.tsx"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table.tsx"; +import React, {useEffect, useState} from 'react'; +import {toast} from "@/components/ui/use-toast.ts"; +import {getErrorMessage} from "@/core/utils/errorHandler.ts"; +import {TeamResponse} from "@/core/types/team.ts"; +import {createTeam, deleteTeam, getTeams, updateTeam} from "@/core/services/teamService.ts"; +import {Pencil, Plus, Trash} from "lucide-react"; +import {Button} from "@/components/ui/button.tsx"; +import {Card} from "@/components/ui/card.tsx"; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table.tsx"; import TeamUpdateDialog from "@/modules/team/components/TeamUpdateDialog.tsx"; -import { DeleteModal } from "@/modules/team/components/TeamDeleteDialog.tsx"; +import {DeleteModal} from "@/modules/team/components/TeamDeleteDialog.tsx"; import PageContent from "@/components/layout/PageContent.tsx"; import PageHeader from "@/components/layout/PageHeader.tsx"; import TeamCreateDialog from "@/modules/team/components/TeamCreateDialog.tsx"; +import {Badge} from "@/components/ui/badge.tsx"; export default function TeamsPage() { const [teamList, setTeamList] = useState([]); @@ -129,6 +130,8 @@ export default function TeamsPage() { Name + Approval Mode + Approver Actions @@ -181,10 +184,30 @@ type TeamItemProps = { setSelectedTeamForDelete: (team: TeamResponse | null) => void; }; -function TeamRowItem({ t, isProcessing, setSelectedTeamForUpdate, setSelectedTeamForDelete }: TeamItemProps) { +function TeamRowItem({t, isProcessing, setSelectedTeamForUpdate, setSelectedTeamForDelete}: TeamItemProps) { + const exampleData = [ + {teamApprover: ['Rozita Hasani', 'Team Admin'], approvalMode: 'Any'}, + {teamApprover: ['John Doe', 'Team Admin'], approvalMode: 'All'}, + ]; + const mockData = exampleData[Math.floor(Math.random() * exampleData.length)]; + return ( {t.name} + + + {mockData.approvalMode === "All" ? "All" : "Any"} + + + + {mockData.teamApprover.map((approver) => ({approver}))} +
- - - + +
+ onSubmit(data, team.id))} className="space-y-4"> + ( + + Team Name + + + + + + )} + /> + + ( + + Approval Mode + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> + + ( + + Team Approvers + +
+ {organizationAdmins.map((admin) => { + const isSelected = field.value.includes(`${admin.firstName} ${admin.lastName}`); + return ( + + ); + })} + + {teamAdmin && ( + + )} +
+
+ +
+ )} + /> + + + + + + + ); diff --git a/src/modules/team/pages/TeamsPage.tsx b/src/modules/team/pages/TeamsPage.tsx index 71fc9d4..5f8d2bf 100644 --- a/src/modules/team/pages/TeamsPage.tsx +++ b/src/modules/team/pages/TeamsPage.tsx @@ -13,6 +13,23 @@ import PageContent from "@/components/layout/PageContent.tsx"; import PageHeader from "@/components/layout/PageHeader.tsx"; import TeamCreateDialog from "@/modules/team/components/TeamCreateDialog.tsx"; import {Badge} from "@/components/ui/badge.tsx"; +import {z} from "zod"; + +const FormSchema = z.object({ + name: z.string().min(2, { + message: "Team name must be at least 2 characters long", + }).max(20, { + message: "Team name must be under 20 characters", + }), + teamApprovers: z.array(z.string()).min(1, { + message: "Please select at least one team approver.", + }), + approvalMode: z.enum(["ALL", "ANY"], { + required_error: "Please select an approval mode.", + }), +}); + +type UpdateTeamInputs = z.infer; export default function TeamsPage() { const [teamList, setTeamList] = useState([]); @@ -62,14 +79,28 @@ export default function TeamsPage() { } }; - const handleUpdateSuccess = async () => { + const handleUpdateTeam = async (data: UpdateTeamInputs, teamId: number) => { try { - const response = await getTeams(); - setTeamList(response); + await updateTeam( + { + name: data.name, + metadata: {}, + teamApprovers: data.teamApprovers, + approvalMode: data.approvalMode, + }, + teamId + ); + await fetchTeams(); + toast({ + title: "Success", + description: "Team updated successfully!", + variant: "default", + }); + setSelectedTeamForUpdate(null); } catch (error) { toast({ title: "Error", - description: "Failed to refresh team list", + description: getErrorMessage(error as Error), variant: "destructive", }); } @@ -79,7 +110,6 @@ export default function TeamsPage() { try { setIsProcessing(true); - // Check if the team name already exists if (teamList.some((t) => t.name === name)) { toast({ title: "Error", @@ -93,7 +123,7 @@ export default function TeamsPage() { name, metadata: {}, teamApprovers, - approvalMode + approvalMode, }); toast({ @@ -102,7 +132,6 @@ export default function TeamsPage() { variant: "default", }); - // Refresh team list after creation await fetchTeams(); } catch (error) { toast({ @@ -118,9 +147,9 @@ export default function TeamsPage() { return ( <> - + @@ -156,11 +185,9 @@ export default function TeamsPage() { {selectedTeamForUpdate && ( setSelectedTeamForUpdate(null)} - onSuccess={handleUpdateSuccess} - updateTeam={updateTeam} + onSubmit={handleUpdateTeam} /> )} @@ -177,6 +204,8 @@ export default function TeamsPage() { ); } +// Rest of the code remains the same... + type TeamItemProps = { t: TeamResponse; isProcessing: boolean; @@ -186,8 +215,8 @@ type TeamItemProps = { function TeamRowItem({t, isProcessing, setSelectedTeamForUpdate, setSelectedTeamForDelete}: TeamItemProps) { const exampleData = [ - {teamApprover: ['Rozita Hasani', 'Team Admin'], approvalMode: 'Any'}, - {teamApprover: ['John Doe', 'Team Admin'], approvalMode: 'All'}, + {teamApprover: ['Rozita Hasani', 'Team Admin'], approvalMode: 'ALL'}, + {teamApprover: ['Team Admin'], approvalMode: 'ANY'}, ]; const mockData = exampleData[Math.floor(Math.random() * exampleData.length)]; @@ -197,12 +226,12 @@ function TeamRowItem({t, isProcessing, setSelectedTeamForUpdate, setSelectedTeam - {mockData.approvalMode === "All" ? "All" : "Any"} + {mockData.approvalMode === "ALL" ? "All" : "Any"} From 4a1c4a7a89df3dcc6f4edc9ca45afe988a0e8f0c Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Tue, 18 Mar 2025 10:29:04 +0100 Subject: [PATCH 4/6] feat: add RadioGroup and RadioGroupItem components --- src/components/ui/radio-group.tsx | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/components/ui/radio-group.tsx diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..9d3a26e --- /dev/null +++ b/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } From 5f528fd31485c1472a7c466a09079694100b749b Mon Sep 17 00:00:00 2001 From: rozita-hasani Date: Tue, 18 Mar 2025 16:25:16 +0100 Subject: [PATCH 5/6] feat: add team approvers and approval mode to leave policy, create and update pages, remove team approvers and approval mode from teams --- src/core/types/leave.ts | 13 +- src/core/types/team.ts | 4 - .../components/LeavePolicyCreateDialog.tsx | 139 +++++++- .../leave/components/LeavePolicyList.tsx | 32 +- .../leave/components/LeavePolicyTable.tsx | 131 ++++---- .../leave/pages/LeavePolicyUpdatePage.tsx | 308 +++++++++--------- .../team/components/TeamCreateDialog.tsx | 146 +-------- .../team/components/TeamUpdateDialog.tsx | 134 +------- src/modules/team/pages/TeamsPage.tsx | 41 +-- 9 files changed, 408 insertions(+), 540 deletions(-) diff --git a/src/core/types/leave.ts b/src/core/types/leave.ts index 2d1186a..0c6cbf5 100644 --- a/src/core/types/leave.ts +++ b/src/core/types/leave.ts @@ -35,6 +35,11 @@ export type LeaveTypeCreateRequest = { symbol: string; } +export enum ApprovalMode { + ALL = 'ALL', + ANY = "ANY" +} + export type LeaveTypeResponse = { id: number; name: string; @@ -49,6 +54,8 @@ export type LeavePolicyCreateRequest = { name: string; status: LeavePolicyStatus; activatedTypes: LeavePolicyActivatedTypeRequest[]; + teamApprovers?: string[]; + approvalMode?: ApprovalMode; } export enum LeavePolicyStatus { @@ -66,7 +73,9 @@ export type LeavePolicyResponse = { id: number; name: string; activatedTypes: LeavePolicyActivatedTypeResponse[]; - isDefault: boolean + isDefault: boolean; + teamApprovers?: string[]; + approvalMode?: ApprovalMode; } export type LeavePolicyActivatedTypeResponse = { @@ -109,6 +118,8 @@ export type LeavePolicyUpdateRequest = { name: string; status: LeavePolicyStatus; activatedTypes: LeavePolicyActivatedTypeRequest[]; + teamApprovers?: string[]; + approvalMode?: ApprovalMode; } export type LeaveCheckRequest = { diff --git a/src/core/types/team.ts b/src/core/types/team.ts index 0c0c4b5..9b3bd26 100644 --- a/src/core/types/team.ts +++ b/src/core/types/team.ts @@ -6,14 +6,10 @@ export type TeamCompactResponse = { export type TeamCreateRequest = { name: string; metadata: Record; - teamApprovers?: string[]; - approvalMode?: "ALL" | "ANY"; } export type TeamResponse = { id: number; name: string; - teamApprovers?: string[]; - approvalMode?: "ALL" | "ANY"; metadata: Record; } \ No newline at end of file diff --git a/src/modules/leave/components/LeavePolicyCreateDialog.tsx b/src/modules/leave/components/LeavePolicyCreateDialog.tsx index 11007bb..774015b 100644 --- a/src/modules/leave/components/LeavePolicyCreateDialog.tsx +++ b/src/modules/leave/components/LeavePolicyCreateDialog.tsx @@ -1,4 +1,3 @@ -import React from "react"; import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"; import {Input} from "@/components/ui/input"; import {Button} from "@/components/ui/button"; @@ -7,9 +6,20 @@ import {useForm} from "react-hook-form"; import {z} from "zod"; import {zodResolver} from "@hookform/resolvers/zod"; import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; +import {ApprovalMode} from "@/core/types/leave.ts"; +import React, {useEffect, useState} from "react"; +import {UserResponse} from "@/core/types/user.ts"; +import {getUsers} from "@/core/services/userService.ts"; +import {UserRole} from "@/core/types/enum.ts"; +import {toast} from "@/components/ui/use-toast.ts"; +import {getErrorMessage} from "@/core/utils/errorHandler.ts"; +import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group.tsx"; +import {Label} from "@/components/ui/label.tsx"; const FormSchema = z.object({ name: z.string().min(2, {message: "Leave policy name must be over 2 characters"}).max(50, {message: "Leave policy name must be under 50 characters"}), + teamApprovers: z.array(z.string()).min(1, {message: "Please select at least one team approver.",}), + approvalMode: z.nativeEnum(ApprovalMode, {required_error: "Please select an approval mode.",}), }); type PolicyInputs = z.infer; @@ -17,19 +27,41 @@ type PolicyInputs = z.infer; type CreatePolicyDialogProps = { isOpen: boolean; onClose: () => void; - onSubmit: (name: string) => void; + onSubmit: (name: string, teamApprover: string[], approvalMode: ApprovalMode) => void; }; export function CreatePolicyDialog({isOpen, onClose, onSubmit}: CreatePolicyDialogProps) { + const [organizationAdmins, setOrganizationAdmins] = useState([]); + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: "", + teamApprovers: [], + approvalMode: ApprovalMode.ANY, }, }); + useEffect(() => { + fetchUsers(); + }, []); + + const fetchUsers = async () => { + try { + const response = await getUsers(0, 100); + const admins = response.contents.filter(user => user.role === UserRole.ORGANIZATION_ADMIN); + setOrganizationAdmins(admins); + } catch (error) { + toast({ + title: "Error", + description: getErrorMessage(error as Error), + variant: "destructive", + }); + } + }; + const handleSubmit = (data: PolicyInputs) => { - onSubmit(data.name); + onSubmit(data.name, data.teamApprovers, data.approvalMode); onClose(); }; @@ -56,6 +88,107 @@ export function CreatePolicyDialog({isOpen, onClose, onSubmit}: CreatePolicyDial )} /> + ( + + Approval Mode + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> + + { + const selectedApprovers = field.value; + + return ( + + Team Approvers + +
+ {organizationAdmins.map((admin) => { + const adminId = admin.id.toString(); + const isSelected = selectedApprovers.includes(adminId); + + return ( + + ); + })} + + +
+
+ +
+ ); + }} + /> +
+ + + + {mockData.approvalMode === ApprovalMode.ALL ? ApprovalMode.ALL : ApprovalMode.ANY} + + + + {mockData.teamApprover.map((approver) => ({approver}))} + +
+
+
+
+ ); + })} + + + {activatedTypes.length === 0 && ( + + + This leave policy doesn't have any leave types + yet. Please click the button above to add a leave type. + + + )} + + ); } \ No newline at end of file diff --git a/src/modules/leave/pages/LeavePolicyUpdatePage.tsx b/src/modules/leave/pages/LeavePolicyUpdatePage.tsx index b630c92..0d7c25f 100644 --- a/src/modules/leave/pages/LeavePolicyUpdatePage.tsx +++ b/src/modules/leave/pages/LeavePolicyUpdatePage.tsx @@ -1,225 +1,225 @@ import React, {useEffect, useState} from "react"; import {useNavigate, useParams} from "react-router-dom"; import {Button} from "@/components/ui/button"; -import {Card, CardHeader, CardTitle} from "@/components/ui/card"; import {Input} from "@/components/ui/input"; -import {Pencil, Plus, Save, X} from "lucide-react"; +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form"; +import {Separator} from "@/components/ui/separator"; +import {Save, X, Plus} from "lucide-react"; +import {useForm} from "react-hook-form"; +import {z} from "zod"; +import {zodResolver} from "@hookform/resolvers/zod"; import {toast} from "@/components/ui/use-toast"; -import {getLeavesPolicy, getLeavesTypes, updateLeavePolicy,} from "@/core/services/leaveService"; -import LeavePolicyActivatedTypeUpdateDialog from "@/modules/leave/components/LeavePolicyActivatedTypeUpdateDialog.tsx"; -import { - LeavePolicyActivatedTypeResponse, - LeavePolicyResponse, - LeavePolicyStatus, - LeaveTypeResponse -} from "@/core/types/leave.ts"; +import {getLeavesPolicy, getLeavesTypes, updateLeavePolicy} from "@/core/services/leaveService"; +import {ApprovalMode, LeavePolicyActivatedTypeResponse, LeavePolicyResponse, LeavePolicyStatus, LeaveTypeResponse} from "@/core/types/leave"; import {getErrorMessage} from "@/core/utils/errorHandler"; -import {LeavePolicyTable} from "@/modules/leave/components/LeavePolicyTable.tsx"; -import PageContent from "@/components/layout/PageContent.tsx"; -import PageHeader from "@/components/layout/PageHeader.tsx"; -import LeavePolicyActivatedTypeCreateDialog from "@/modules/leave/components/LeavePolicyActivatedTypeCreateDialog.tsx"; +import LeavePolicyActivatedTypeCreateDialog from "@/modules/leave/components/LeavePolicyActivatedTypeCreateDialog"; +import LeavePolicyActivatedTypeUpdateDialog from "@/modules/leave/components/LeavePolicyActivatedTypeUpdateDialog"; +import {LeavePolicyTable} from "@/modules/leave/components/LeavePolicyTable"; +import PageContent from "@/components/layout/PageContent"; +import PageHeader from "@/components/layout/PageHeader"; +import {Card, CardContent } from "@/components/ui/card"; + +const FormSchema = z.object({ + name: z.string().min(1, "Policy name is required"), + teamApprovers: z.array(z.string()).min(1, "At least one approver is required"), + approvalMode: z.nativeEnum(ApprovalMode), +}); + +type LeavePolicyFormInputs = z.infer; export default function LeavePolicyUpdatePage() { const {id} = useParams(); + const navigate = useNavigate(); const [leavePolicy, setLeavePolicy] = useState(null); const [leaveTypes, setLeaveTypes] = useState([]); const [selectedLeaveType, setSelectedLeaveType] = useState(null); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); - const [isEditingName, setIsEditingName] = useState(false); - const navigate = useNavigate(); - // Fetch Leave Policy + const commonApprovers = ["Team Admin", "HR Manager", "Department Head", "Line Manager", "Team Lead"]; + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: "", + teamApprovers: ["Team Admin"], + approvalMode: ApprovalMode.ANY, + }, + }); + useEffect(() => { - const fetchLeavePolicy = async () => { + const fetchData = async () => { try { const policy = await getLeavesPolicy(Number(id)); setLeavePolicy(policy); - } catch (error) { - toast({ - title: "Error", - description: getErrorMessage(error as Error | string), - variant: "destructive", + form.reset({ + name: policy.name ?? "", + teamApprovers: policy.teamApprovers ?? ["Team Admin"], + approvalMode: policy.approvalMode ?? ApprovalMode.ANY, }); + } catch (error) { + toast({ title: "Error", description: getErrorMessage(error as Error), variant: "destructive" }); } }; - fetchLeavePolicy(); - }, [id]); - - // Fetch leave Types - useEffect(() => { const fetchLeaveTypes = async () => { try { const types = await getLeavesTypes(); setLeaveTypes(types); } catch (error) { - toast({ - title: "Error", - description: getErrorMessage(error as Error), - variant: "destructive", - }); + toast({ title: "Error", description: getErrorMessage(error as Error), variant: "destructive" }); } }; + fetchData(); fetchLeaveTypes(); - }, []); + }, [id]); - // Handle Policy Name Change - const savePolicyName = async () => { + const handleSave = async (data: LeavePolicyFormInputs) => { if (!leavePolicy) return; - const updatedName = leavePolicy.name; - - if (!updatedName) { - toast({ - title: "Error", - description: "Policy name cannot be empty", - variant: "destructive", - }); - return; + try { + await updateLeavePolicy({ + name: data.name, + activatedTypes: leavePolicy.activatedTypes, + status: LeavePolicyStatus.ACTIVE, + teamApprovers: data.teamApprovers, + approvalMode: data.approvalMode, + }, leavePolicy.id); + + toast({ title: "Success", description: "Leave policy updated successfully" }); + navigate("/leaves/policies"); + } catch (error) { + toast({ title: "Error", description: getErrorMessage(error as Error), variant: "destructive" }); } - - setLeavePolicy({...leavePolicy, name: updatedName}); - setIsEditingName(false); }; - // Handle Add Leave Type const addLeaveType = (newType: LeavePolicyActivatedTypeResponse) => { if (!leavePolicy) return; - - setLeavePolicy({ - ...leavePolicy, - activatedTypes: [...leavePolicy.activatedTypes, newType], - }); - + setLeavePolicy({ ...leavePolicy, activatedTypes: [...leavePolicy.activatedTypes, newType] }); setIsCreateDialogOpen(false); }; - // Handle Update Leave Type const updateLeaveType = (updatedType: LeavePolicyActivatedTypeResponse) => { if (!leavePolicy) return; - setLeavePolicy({ ...leavePolicy, - activatedTypes: leavePolicy.activatedTypes.map((type) => + activatedTypes: leavePolicy.activatedTypes.map(type => type.typeId === updatedType.typeId ? updatedType : type ), }); - setIsUpdateDialogOpen(false); setSelectedLeaveType(null); }; - // Handle Remove Leave Type const removeLeaveType = (typeId: number) => { if (!leavePolicy) return; - setLeavePolicy({ ...leavePolicy, - activatedTypes: leavePolicy.activatedTypes.filter((type) => type.typeId !== typeId), + activatedTypes: leavePolicy.activatedTypes.filter(type => type.typeId !== typeId), }); }; - // Save Final Changes - const savePolicy = async () => { - if (!leavePolicy) return; - - try { - await updateLeavePolicy( - { - name: leavePolicy.name, - activatedTypes: leavePolicy.activatedTypes, - status: LeavePolicyStatus.ACTIVE, - }, - leavePolicy.id - ); - - toast({ - title: "Success", - description: "Leave policy updated successfully", - variant: "default", - }); - - navigate("/leaves/policies"); - } catch (error) { - toast({ - title: "Error", - description: getErrorMessage(error as Error), - variant: "destructive", - }); - } - }; - return ( <> - - - {isEditingName ? ( - - setLeavePolicy({...leavePolicy!, name: e.target.value})} - placeholder="Enter policy name" - className="flex-1" - /> - - - ) : ( - - - {leavePolicy?.name} - + + + + )} /> + + ( + + Team Approvers +
+ {commonApprovers.map((approver) => { + const isSelected = field.value.includes(approver); + return ( + + ); + })} +
+ +
+ )} /> + + +
+

Leave Types

+ + { + setSelectedLeaveType(type); + setIsUpdateDialogOpen(true); + }} + onRemove={removeLeaveType} + /> +
+ +
+ - - - )} - - - { - setSelectedLeaveType(type); - setIsUpdateDialogOpen(true); - }} - onRemove={removeLeaveType} - /> + +
+ + + - - {leavePolicy?.activatedTypes && ( -
- - -
- )}
); -} \ No newline at end of file +} diff --git a/src/modules/team/components/TeamCreateDialog.tsx b/src/modules/team/components/TeamCreateDialog.tsx index 6faf40b..fad686e 100644 --- a/src/modules/team/components/TeamCreateDialog.tsx +++ b/src/modules/team/components/TeamCreateDialog.tsx @@ -1,4 +1,3 @@ -import React, {useEffect, useState} from "react"; import {useForm} from "react-hook-form"; import {Button} from "@/components/ui/button"; import {z} from "zod"; @@ -7,26 +6,9 @@ import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/ import {Input} from "@/components/ui/input"; import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"; import {Save, X} from "lucide-react"; -import {getUsers} from "@/core/services/userService"; -import {UserRole} from "@/core/types/enum"; -import {UserResponse} from "@/core/types/user.ts"; -import {toast} from "@/components/ui/use-toast.ts"; -import {getErrorMessage} from "@/core/utils/errorHandler.ts"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Label } from "@/components/ui/label"; const FormSchema = z.object({ - name: z.string().min(2, { - message: "Team name must be at least 2 characters long", - }).max(20, { - message: "Team name must be under 20 characters", - }), - teamApprovers: z.array(z.string()).min(1, { - message: "Please select at least one team approver.", - }), - approvalMode: z.enum(["ALL", "ANY"], { - required_error: "Please select an approval mode.", - }), + name: z.string().min(2, {message: "Team name must be at least 2 characters long",}).max(20, {message: "Team name must be under 20 characters",}) }); type CreateTeamInputs = z.infer; @@ -34,57 +16,19 @@ type CreateTeamInputs = z.infer; interface TeamCreateDialogProps { onClose: () => void; isOpen: boolean; - onSubmit: ( - name: string, - teamApprovers: string[], - approvalMode: "ALL" | "ANY" - ) => void; + onSubmit: (name: string,) => void; } export default function TeamCreateDialog({isOpen, onClose, onSubmit}: TeamCreateDialogProps) { - const [organizationAdmins, setOrganizationAdmins] = useState([]); - const [isTeamAdminSelected, setIsTeamAdminSelected] = useState(false); - const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { - name: "", - teamApprovers: [], - approvalMode: "ALL", + name: "" }, }); - useEffect(() => { - fetchUsers(); - }, []); - - const fetchUsers = async () => { - try { - const response = await getUsers(0, 100); - const admins = response.contents.filter(user => user.role === UserRole.ORGANIZATION_ADMIN); - setOrganizationAdmins(admins); - } catch (error) { - toast({ - title: "Error", - description: getErrorMessage(error as Error), - variant: "destructive", - }); - } - }; - - const handleTeamAdminToggle = () => { - setIsTeamAdminSelected(!isTeamAdminSelected); - const currentApprovers = form.getValues("teamApprovers"); - - if (isTeamAdminSelected) { - form.setValue("teamApprovers", currentApprovers.filter((item) => item !== "Team Admin")); - } else { - form.setValue("teamApprovers", [...currentApprovers, "Team Admin"]); - } - }; - const handleSubmit = (data: CreateTeamInputs) => { - onSubmit(data.name, data.teamApprovers, data.approvalMode); + onSubmit(data.name); onClose(); }; @@ -111,88 +55,6 @@ export default function TeamCreateDialog({isOpen, onClose, onSubmit}: TeamCreate )} /> - ( - - Approval Mode - - -
- - -
-
- - -
-
-
- -
- )} - /> - - ( - - Team Approvers - -
- {organizationAdmins.map((admin) => { - const isSelected = field.value.includes(`${admin.firstName} ${admin.lastName}`); - return ( - - ); - })} - -
-
- -
- )} - /> - - ); - })} - - {teamAdmin && ( - - )} - - - - - )} - /> - - ); - })} - - - - - - - ); - }} - /> -