diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/actions/update-employee.ts b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/actions/update-employee.ts index c46307f82b..35c33b422d 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/actions/update-employee.ts +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/actions/update-employee.ts @@ -10,98 +10,121 @@ import type { Departments } from "@comp/db/types"; import { headers } from "next/headers"; const schema = z.object({ - employeeId: z.string(), - department: z.string().optional(), - isActive: z.boolean().optional(), + employeeId: z.string(), + name: z.string().min(1, "Name cannot be empty").optional(), + email: z.string().email("Invalid email format").optional(), + department: z.string().optional(), + isActive: z.boolean().optional(), }); export const updateEmployee = authActionClient - .schema(schema) - .metadata({ - name: "update-employee", - track: { - event: "update-employee", - channel: "server", - }, - }) - .action( - async ({ - parsedInput, - }): Promise< - { success: true; data: any } | { success: false; error: any } - > => { - const { employeeId, department, isActive } = parsedInput; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } - - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - }); - - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND, - }; - } - - // Build update data based on provided values - const updateData: { department?: Departments; isActive?: boolean } = {}; - - // Only include fields that were provided - if (department !== undefined) { - updateData.department = department as Departments; - } - - if (isActive !== undefined) { - updateData.isActive = isActive; - } - - // Only update if there are changes - if (Object.keys(updateData).length === 0) { - return { - success: true, - data: employee, - }; - } - - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: updateData, - }); - - // Revalidate related paths - revalidatePath(`/${organizationId}/employees/${employeeId}`); - revalidatePath(`/${organizationId}/employees`); - - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error("Error updating employee:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }, - ); + .schema(schema) + .metadata({ + name: "update-employee", + track: { + event: "update-employee", + channel: "server", + }, + }) + .action( + async ({ + parsedInput, + }): Promise< + { success: true; data: any } | { success: false; error: any } + > => { + const { employeeId, name, email, department, isActive } = parsedInput; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } + + try { + const member = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + }); + + if (!member) { + return { + success: false, + error: appErrors.NOT_FOUND, + }; + } + + const memberUpdateData: { + department?: Departments; + isActive?: boolean; + } = {}; + const userUpdateData: { name?: string; email?: string } = {}; + + if (department !== undefined && department !== member.department) { + memberUpdateData.department = department as Departments; + } + if (isActive !== undefined && isActive !== member.isActive) { + memberUpdateData.isActive = isActive; + } + if (name !== undefined) { + userUpdateData.name = name; + } + if (email !== undefined) { + userUpdateData.email = email; + } + + const hasMemberChanges = Object.keys(memberUpdateData).length > 0; + const hasUserChanges = Object.keys(userUpdateData).length > 0; + + if (!hasMemberChanges && !hasUserChanges) { + return { + success: true, + data: member, + }; + } + + const updatedMember = await db.$transaction(async (tx) => { + if (hasUserChanges) { + await tx.user.update({ + where: { id: member.userId }, + data: userUpdateData, + }); + } + + if (hasMemberChanges) { + return tx.member.update({ + where: { + id: employeeId, + organizationId, + }, + data: memberUpdateData, + }); + } + + return member; + }); + + revalidatePath(`/${organizationId}/employees/${employeeId}`); + revalidatePath(`/${organizationId}/employees`); + + return { + success: true, + data: updatedMember, + }; + } catch (error) { + console.error("Error updating employee:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR, + }; + } + } + ); diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/components/EmployeeDetails.tsx index 4817009ba7..0c932d01ef 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/employees/[employeeId]/components/EmployeeDetails.tsx @@ -19,6 +19,16 @@ import { CardHeader, CardTitle, } from "@comp/ui/card"; +import { cn } from "@comp/ui/cn"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@comp/ui/form"; +import { Input } from "@comp/ui/input"; import { Select, SelectContent, @@ -28,11 +38,14 @@ import { } from "@comp/ui/select"; import { Skeleton } from "@comp/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@comp/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; import { AlertCircle, CheckCircle2, Save } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { redirect, useParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import { z } from "zod"; import { useEmployeeDetails } from "../../all/hooks/useEmployee"; import { updateEmployee } from "../actions/update-employee"; @@ -51,6 +64,11 @@ const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ { value: "inactive", label: "Inactive" }, ]; +const EMPLOYEE_STATUS_HEX_COLORS: Record = { + inactive: "#ef4444", + active: "#10b981", +}; + interface EmployeeDetailsProps { employeeId: string; employee: Member & { @@ -62,6 +80,24 @@ interface EmployeeDetailsProps { })[]; } +// Form validation schema +const employeeFormSchema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Please enter a valid email address"), + department: z.enum([ + "admin", + "gov", + "hr", + "it", + "itsm", + "qms", + "none", + ] as const), + status: z.enum(["active", "inactive"] as const), +}); + +type EmployeeFormValues = z.infer; + export function EmployeeDetails({ employeeId, employee, @@ -71,37 +107,22 @@ export function EmployeeDetails({ const { isLoading, error, mutate } = useEmployeeDetails(employeeId); const { orgId } = useParams<{ orgId: string }>(); const [isSaving, setIsSaving] = useState(false); - const [department, setDepartment] = useState(null); - const [status, setStatus] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - // Set initial values when employee data loads - useEffect(() => { - if (employee) { - setDepartment(employee.department as Departments); - setStatus(employee.isActive ? "active" : "inactive"); - } - }, [employee]); - - // Track changes - useEffect(() => { - if (employee) { - const departmentChanged = - department !== null && department !== employee.department; - const statusChanged = - status !== null && - ((status === "active" && !employee.isActive) || - (status === "inactive" && employee.isActive)); - - setHasChanges(departmentChanged || statusChanged); - } - }, [department, status, employee]); + // Initialize form with react-hook-form + const form = useForm({ + resolver: zodResolver(employeeFormSchema), + defaultValues: { + name: employee.user.name ?? "", + email: employee.user.email ?? "", + department: employee.department as Departments, + status: employee.isActive ? "active" : "inactive", + }, + }); const { execute } = useAction(updateEmployee, { onSuccess: () => { toast.success("Employee details updated successfully"); mutate(); - setHasChanges(false); }, onError: (error) => { toast.error( @@ -140,40 +161,22 @@ export function EmployeeDetails({ if (!employee) return null; - const handleDepartmentChange = (value: Departments) => { - setDepartment(value); - }; - - const handleStatusChange = (value: EmployeeStatusType) => { - setStatus(value); - }; - - const handleSave = async () => { + const onSubmit = async (data: EmployeeFormValues) => { setIsSaving(true); try { - // Prepare update data - const updateData: { - employeeId: string; - department?: string; - isActive?: boolean; - } = { employeeId }; - - // Only include changed fields - if (department && department !== employee.department) { - updateData.department = department; - } - - if (status) { - const isActive = status === "active"; - if (isActive !== employee.isActive) { - updateData.isActive = isActive; - } - } + // Prepare update data with all fields + const updateData = { + employeeId, + name: data.name, + email: data.email, + department: data.department, + isActive: data.status === "active", + }; // Execute the update await execute(updateData); } catch (error) { - toast.error("Failed to update employee details"); + // Error handled by useAction hook's onError } finally { setIsSaving(false); } @@ -191,96 +194,165 @@ export function EmployeeDetails({ Manage employee information and department assignment

- - {/* Personal Info Section */} -
-

- Personal Info -

-
-
-

Name

-

{employee.user.name}

-
+
+ + + {/* Personal Info Section */}
-

Email

-

{employee.user.email}

+

+ Personal Info +

+
+ ( + + + Name + + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> + ( + + + Email + + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> +
-
-
- {/* Department & Status Row */} -
-
-

- Department -

- -
+ {/* Department & Status Row */} +
+ ( + + + Department + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> -
-

- Status -

- -
-
+ ( + + + Status + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + /> +
- {/* Join Date Row */} -
-
-

- Join Date -

-

- {formatDate(employee.createdAt.toISOString(), "MMM d, yyyy")} -

-
-
-
- - - - + {/* Join Date Row */} +
+
+

+ Join Date +

+

+ {formatDate( + employee.createdAt.toISOString(), + "MMM d, yyyy", + )} +

+
+
+
+ + + + + + {/* Tasks Section */} diff --git a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[evidenceId]/components/EvidenceStatusSection.tsx b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[evidenceId]/components/EvidenceStatusSection.tsx index 45a4f81efa..60c261b4ea 100644 --- a/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[evidenceId]/components/EvidenceStatusSection.tsx +++ b/apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/evidence/[evidenceId]/components/EvidenceStatusSection.tsx @@ -1,74 +1,72 @@ +import { EvidenceStatus } from "@comp/db/types"; import { cn } from "@comp/ui/cn"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; -import { FileCheck } from "lucide-react"; -import { EvidenceStatus } from "@comp/db/types"; import { EVIDENCE_STATUS_HEX_COLORS } from "../../(overview)/constants/evidence-status"; -import { FormLabel } from "@comp/ui/form"; const statusOptions: { value: EvidenceStatus; label: string }[] = [ - { value: "draft", label: "Draft" }, - { value: "published", label: "Published" }, - { value: "not_relevant", label: "Not Relevant" }, + { value: "draft", label: "Draft" }, + { value: "published", label: "Published" }, + { value: "not_relevant", label: "Not Relevant" }, ]; export const EvidenceStatusSection = ({ - status, - handleStatusChange, - isSaving, + status, + handleStatusChange, + isSaving, }: { - status: EvidenceStatus; - handleStatusChange: (value: EvidenceStatus) => void; - isSaving: boolean; + status: EvidenceStatus; + handleStatusChange: (value: EvidenceStatus) => void; + isSaving: boolean; }) => { - return ( -
-
- Status -
- -
- ); + return ( +
+
+ Status +
+ +
+ ); }; diff --git a/apps/app/src/components/settings/team/members-list.tsx b/apps/app/src/components/settings/team/members-list.tsx index 216658dae5..7fa9110595 100644 --- a/apps/app/src/components/settings/team/members-list.tsx +++ b/apps/app/src/components/settings/team/members-list.tsx @@ -11,32 +11,14 @@ import { CardHeader, CardTitle, } from "@comp/ui/card"; -import type { Organization, Role } from "@prisma/client"; +import type { Member, Role, User } from "@prisma/client"; import { Crown, UserCheck, UserCog, UserMinus } from "lucide-react"; -interface Member { - id: string; - organizationId: string; - userId: string; - role: Role; - createdAt: Date; - teamId?: string; - user: { - email: string; - name: string; - image?: string; - }; -} - -export interface OrganizationWithMembers extends Organization { - members: Member[]; -} - interface MembersListProps { - organization?: OrganizationWithMembers; + members: (Member & { user: User })[]; } -export function MembersList({ organization }: MembersListProps) { +export function MembersList({ members }: MembersListProps) { const t = useI18n(); return ( @@ -49,7 +31,7 @@ export function MembersList({ organization }: MembersListProps) {
- {organization?.members.map((member) => { + {members.map((member) => { return (
@@ -28,9 +27,7 @@ export async function TeamMembers() { - {organization ? ( - - ) : null} + @@ -43,7 +40,7 @@ export async function TeamMembers() { ); } -const getOrganizationMembers = cache(async () => { +const getMembers = cache(async () => { const session = await auth.api.getSession({ headers: await headers(), }); @@ -52,10 +49,19 @@ const getOrganizationMembers = cache(async () => { return null; } - return auth.api.getFullOrganization({ - organizationId: session?.session.activeOrganizationId, - headers: await headers(), + const members = await db.member.findMany({ + where: { + organizationId: session?.session.activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, }); + + return members; }); const getOrganizationId = cache(async () => {