diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts deleted file mode 100644 index aa95073b8..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts +++ /dev/null @@ -1,69 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { auth } from '@/utils/auth'; -import { db } from '@db'; -import { headers } from 'next/headers'; -import { type AppError, appErrors, employeeDetailsInputSchema } from '../types'; - -// Type-safe action response -export type ActionResponse = Promise< - { success: true; data: T } | { success: false; error: AppError } ->; - -export const getEmployeeDetails = authActionClient - .inputSchema(employeeDetailsInputSchema) - .metadata({ - name: 'get-employee-details', - track: { - event: 'get-employee-details', - channel: 'server', - }, - }) - .action(async ({ parsedInput }) => { - const { employeeId } = parsedInput; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - throw new Error('Organization ID not found'); - } - - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - select: { - id: true, - department: true, - createdAt: true, - isActive: true, - user: true, - }, - }); - - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND.message, - }; - } - - return { - success: true, - data: employee, - }; - } catch (error) { - console.error('Error fetching employee details:', error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts deleted file mode 100644 index 13f166395..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts +++ /dev/null @@ -1,80 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { auth } from '@/utils/auth'; -import type { Departments } from '@db'; -import { db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { type AppError, appErrors, updateEmployeeDepartmentSchema } from '../types'; - -export type ActionResponse = Promise< - { success: true; data: T } | { success: false; error: AppError } ->; - -export const updateEmployeeDepartment = authActionClient - .inputSchema(updateEmployeeDepartmentSchema) - .metadata({ - name: 'update-employee-department', - track: { - event: 'update-employee-department', - channel: 'server', - }, - }) - .action(async ({ parsedInput }): Promise => { - const { employeeId, department } = 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, - }; - } - - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - department: department as Departments, - }, - }); - - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); - - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error('Error updating employee department:', error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts deleted file mode 100644 index 39e12a432..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts +++ /dev/null @@ -1,91 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { auth } from '@/utils/auth'; -import { db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; -import { appErrors } from '../types'; - -const schema = z.object({ - employeeId: z.string(), - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email address'), -}); - -export const updateEmployeeDetails = authActionClient - .inputSchema(schema) - .metadata({ - name: 'update-employee-details', - track: { - event: 'update-employee-details', - channel: 'server', - }, - }) - .action( - async ({ - parsedInput, - }): Promise<{ success: true; data: any } | { success: false; error: any }> => { - const { employeeId, name, email } = 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, - }; - } - - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - user: { - update: { - name, - email, - }, - }, - }, - }); - - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); - - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error('Error updating employee details:', error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }, - ); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts deleted file mode 100644 index bde8ccf74..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts +++ /dev/null @@ -1,85 +0,0 @@ -'use server'; - -import { authActionClient } from '@/actions/safe-action'; -import { auth } from '@/utils/auth'; -import { db } from '@db'; -import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; -import { z } from 'zod'; -import { appErrors } from '../types'; - -const schema = z.object({ - employeeId: z.string(), - isActive: z.boolean(), -}); - -export const updateEmployeeStatus = authActionClient - .inputSchema(schema) - .metadata({ - name: 'update-employee-status', - track: { - event: 'update-employee-status', - channel: 'server', - }, - }) - .action( - async ({ - parsedInput, - }): Promise<{ success: true; data: any } | { success: false; error: any }> => { - const { employeeId, 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, - }; - } - - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - isActive, - }, - }); - - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); - - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error('Error updating employee status:', error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }, - ); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts index cee6a511a..d902f73ac 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts @@ -29,7 +29,35 @@ export const updateEmployee = authActionClient const { employeeId, name, email, department, isActive, createdAt } = parsedInput; const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) throw new Error(appErrors.UNAUTHORIZED.message); + if (!organizationId) { + return { + success: false, + error: { + code: appErrors.UNAUTHORIZED, + message: appErrors.UNAUTHORIZED.message, + }, + }; + } + + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: organizationId, + userId: ctx.user.id, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + return { + success: false, + error: { + code: appErrors.UNAUTHORIZED, + message: "You don't have permission to update members.", + }, + }; + } const member = await db.member.findUnique({ where: { @@ -40,7 +68,13 @@ export const updateEmployee = authActionClient }); if (!member || !member.user) { - throw new Error(appErrors.NOT_FOUND.message); + return { + success: false, + error: { + code: appErrors.NOT_FOUND, + message: appErrors.NOT_FOUND.message, + }, + }; } const memberUpdateData: { @@ -110,7 +144,13 @@ export const updateEmployee = authActionClient if (error.code === 'P2002') { const targetFields = error.meta?.target as string[] | undefined; if (targetFields?.includes('email')) { - throw new Error('Email address is already in use.'); + return { + success: false, + error: { + code: appErrors.UNEXPECTED_ERROR, + message: 'Email address is already in use.', + }, + }; } } } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx deleted file mode 100644 index 2f503b158..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Departments } from '@db'; -import { useAction } from 'next-safe-action/hooks'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import { updateEmployeeDepartment } from '../actions/update-department'; - -const DEPARTMENTS = [ - { value: 'admin', label: 'Admin' }, - { value: 'gov', label: 'Governance' }, - { value: 'hr', label: 'HR' }, - { value: 'it', label: 'IT' }, - { value: 'itsm', label: 'IT Service Management' }, - { value: 'qms', label: 'Quality Management' }, - { value: 'none', label: 'None' }, -]; - -interface EditableDepartmentProps { - employeeId: string; - currentDepartment: Departments; - onSuccess?: () => void; -} - -export function EditableDepartment({ - employeeId, - currentDepartment, - onSuccess, -}: EditableDepartmentProps) { - const [department, setDepartment] = useState(currentDepartment); - - const { execute, status } = useAction(updateEmployeeDepartment, { - onSuccess: () => { - toast.success('Department updated successfully'); - onSuccess?.(); - }, - onError: (error) => { - toast.error(error?.error?.serverError || 'Failed to update department'); - }, - }); - - const handleSave = () => { - execute({ employeeId, department }); - }; - - return ( -
- -
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx deleted file mode 100644 index 34b62158a..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -interface EditableDetailsProps { - employeeId: string; - currentName: string; - currentEmail: string; - onSuccess?: () => void; -} - -export function EditableDetails({ employeeId, currentName, currentEmail }: EditableDetailsProps) { - return ( -
-
- Name - {currentName} -
-
- Email - {currentEmail} -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx deleted file mode 100644 index 7163c2463..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import type { EmployeeStatusType } from '@/components/tables/people/employee-status'; -import { getEmployeeStatusFromBoolean } from '@/components/tables/people/employee-status'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { useAction } from 'next-safe-action/hooks'; -import { useState } from 'react'; -import { toast } from 'sonner'; -import { updateEmployeeStatus } from '../actions/update-employee-status'; - -const STATUS_OPTIONS = [ - { value: 'active', label: 'Active' }, - { value: 'inactive', label: 'Inactive' }, -]; - -interface EditableStatusProps { - employeeId: string; - currentStatus: boolean; - onSuccess?: () => void; -} - -export function EditableStatus({ employeeId, currentStatus, onSuccess }: EditableStatusProps) { - const initialStatus = getEmployeeStatusFromBoolean(currentStatus); - const [status, setStatus] = useState(initialStatus); - - const { execute, status: actionStatus } = useAction(updateEmployeeStatus, { - onSuccess: () => { - toast.success('Employee status updated successfully'); - onSuccess?.(); - }, - onError: (error) => { - toast.error(error?.error?.serverError || 'Failed to update employee status'); - }, - }); - - const handleSave = () => { - const isActive = status === 'active'; - execute({ employeeId, isActive }); - }; - - return ( -
- -
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 8e786a0b0..25bb950b2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -16,6 +16,7 @@ interface EmployeeDetailsProps { })[]; fleetPolicies: FleetPolicy[]; host: Host; + canEdit: boolean; } export function Employee({ @@ -24,10 +25,11 @@ export function Employee({ trainingVideos, fleetPolicies, host, + canEdit, }: EmployeeDetailsProps) { return (
- + ; export const EmployeeDetails = ({ employee, + canEdit, }: { employee: Member & { user: User; }; + canEdit: boolean; }) => { const form = useForm({ resolver: zodResolver(employeeFormSchema), @@ -48,7 +50,11 @@ export const EmployeeDetails = ({ }); const { execute, status: actionStatus } = useAction(updateEmployee, { - onSuccess: () => { + onSuccess: (res) => { + if (!res?.data?.success) { + toast.error(res?.data?.error?.message || 'Failed to update employee details'); + return; + } toast.success('Employee details updated successfully'); }, onError: (error) => { @@ -107,11 +113,11 @@ export const EmployeeDetails = ({
- - - - - + + + + +
diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx index 6bcc57493..c08004b8e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx @@ -14,7 +14,13 @@ const DEPARTMENTS: { value: Departments; label: string }[] = [ { value: 'none', label: 'None' }, ]; -export const Department = ({ control }: { control: Control }) => { +export const Department = ({ + control, + disabled, +}: { + control: Control; + disabled: boolean; +}) => { return ( } Department - diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx index cf9f8803f..af31cdd29 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx @@ -3,7 +3,13 @@ import { Input } from '@comp/ui/input'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; -export const Email = ({ control }: { control: Control }) => { +export const Email = ({ + control, + disabled, +}: { + control: Control; + disabled: boolean; +}) => { return ( }) => EMAIL - + diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx index 0c96a087d..10b8ed210 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx @@ -8,7 +8,13 @@ import { CalendarIcon } from 'lucide-react'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; -export const JoinDate = ({ control }: { control: Control }) => { +export const JoinDate = ({ + control, + disabled, +}: { + control: Control; + disabled: boolean; +}) => { return ( }) Join Date - + @@ -199,7 +199,7 @@ export function MemberRow({ member, onRemove, onUpdateRole }: MemberRowProps) { } }} > - {canEditRoles && ( + {canEdit && ( Promise; + canCancel: boolean; } -export function PendingInvitationRow({ invitation, onCancel }: PendingInvitationRowProps) { +export function PendingInvitationRow({ + invitation, + onCancel, + canCancel, +}: PendingInvitationRowProps) { const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); const [isCancelling, setIsCancelling] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); @@ -105,7 +110,11 @@ export function PendingInvitationRow({ invitation, onCancel }: PendingInvitation ))}
- + @@ -241,6 +248,7 @@ export function TeamMembersClient({ member={member as MemberWithUser} onRemove={handleRemoveMember} onUpdateRole={handleUpdateRole} + canEdit={canManageMembers} /> ))} @@ -255,6 +263,7 @@ export function TeamMembersClient({ key={invitation.displayId} invitation={invitation as Invitation} onCancel={handleCancelInvitation} + canCancel={canManageMembers} /> ))} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx index b2387fec8..7392ab283 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx @@ -20,10 +20,7 @@ export default async function TaskPage({ }); console.log('[TaskPage] Session obtained, fetching data'); - const [task, members] = await Promise.all([ - getTask(taskId, session), - getMembers(orgId, session), - ]); + const [task, members] = await Promise.all([getTask(taskId, session), getMembers(orgId, session)]); if (!task) { redirect(`/${orgId}/tasks`); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/actions/updateTaskStatus.ts b/apps/app/src/app/(app)/[orgId]/tasks/actions/updateTaskStatus.ts new file mode 100644 index 000000000..6f88ee2ba --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/actions/updateTaskStatus.ts @@ -0,0 +1,68 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const updateTaskStatusSchema = z.object({ + id: z.string(), + status: z.enum(['todo', 'in_progress', 'done', 'not_relevant']), +}); + +export const updateTaskStatusAction = authActionClient + .inputSchema(updateTaskStatusSchema) + .metadata({ + name: 'update-task-status', + track: { + event: 'update_task_status', + description: 'Update Task Status from List View', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, status } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { + success: false, + error: 'Not authorized', + }; + } + + try { + const task = await db.task.findUnique({ + where: { + id, + organizationId: activeOrganizationId, + }, + }); + + if (!task) { + return { + success: false, + error: 'Task not found', + }; + } + + // Update the task status + await db.task.update({ + where: { id }, + data: { status }, + }); + + // Revalidate paths to update UI + revalidatePath(`/${activeOrganizationId}/tasks`); + + return { + success: true, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: 'Failed to update task status', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx index c03c04cb6..684de15d0 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx @@ -1,11 +1,11 @@ 'use client'; -import type { Member, Task, TaskStatus, User } from '@db'; +import type { Member, Task, User } from '@db'; import Image from 'next/image'; import { usePathname, useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useDrag, useDrop } from 'react-dnd'; -import { TaskStatusIndicator } from './TaskStatusIndicator'; +import { TaskStatusSelector } from './TaskStatusSelector'; // DnD Item Type identifier for tasks. export const ItemTypes = { @@ -85,8 +85,6 @@ export function TaskCard({ return; } - const dragIndex = item.index; - const hoverIndex = index; const clientOffset = monitor.getClientOffset(); if (!clientOffset) return; @@ -151,20 +149,6 @@ export function TaskCard({ return members.find((m) => m.id === task.assigneeId); }, [task.assigneeId, members]); - // Helper to get Tailwind class for the entity type indicator dot. - const getEntityTypeDotClass = (entityType: 'control' | 'risk' | 'vendor'): string => { - switch (entityType) { - case 'control': - return 'bg-blue-500'; - case 'risk': - return 'bg-red-500'; - case 'vendor': - return 'bg-green-500'; - default: - return 'bg-gray-500'; - } - }; - // Navigation handler for clicking on the task card. const handleNavigate = () => { const targetPath = `${pathname}/${task.id}`; @@ -174,9 +158,8 @@ export function TaskCard({ return (
{/* Reorder Indicator (Solid Line) */} @@ -203,26 +186,32 @@ export function TaskCard({
::
-
- +
+
- {task.title} -
- Apr 15 -
- {assignedMember?.user?.image ? ( - {assignedMember.user.name - ) : ( - - {assignedMember?.user?.name?.charAt(0) ?? '?'} - - )} + +
+ {task.title} +
+ Apr 15 +
+ {assignedMember?.user?.image ? ( + {assignedMember.user.name + ) : ( + + {assignedMember?.user?.name?.charAt(0) ?? '?'} + + )} +
diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusSelector.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusSelector.tsx new file mode 100644 index 000000000..77853e00b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusSelector.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { Button } from '@comp/ui/button'; +import type { Task, TaskStatus } from '@db'; +import { useAction } from 'next-safe-action/hooks'; +import { toast } from 'sonner'; +import { PropertySelector } from '../[taskId]/components/PropertySelector'; +import { taskStatuses } from '../[taskId]/components/constants'; +import { updateTaskStatusAction } from '../actions/updateTaskStatus'; +import { TaskStatusIndicator } from './TaskStatusIndicator'; + +interface TaskStatusSelectorProps { + task: Task; +} + +export function TaskStatusSelector({ task }: TaskStatusSelectorProps) { + const { execute, status } = useAction(updateTaskStatusAction, { + onSuccess: (res) => { + if (res?.data.success) { + return; + } else { + console.error('Failed to update task status:', res?.data.error); + toast.error(`Failed to update task status: ${res?.data.error || 'Unknown error'}`); + } + }, + onError: (error) => { + console.error('Failed to update task status:', error); + toast.error('Failed to update task status.'); + }, + }); + + const isUpdating = status === 'executing'; + + const handleStatusSelect = (selectedStatus: string | null) => { + if (selectedStatus && selectedStatus !== task.status) { + execute({ + id: task.id, + status: selectedStatus as TaskStatus, + }); + } + }; + + return ( + + value={task.status} + options={taskStatuses} + getKey={(status) => status} + renderOption={(status) => ( +
+ + {status.replace('_', ' ')} +
+ )} + onSelect={handleStatusSelect} + trigger={ + + } + searchPlaceholder="Change status..." + emptyText="No status found." + contentWidth="w-48" + disabled={isUpdating} + /> + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx index 67441bc66..162fd59ce 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx @@ -38,9 +38,6 @@ export default async function TasksPage({ } const getTasks = async (statusParam?: string) => { - console.log('Fetching tasks...', { - statusParam, - }); const session = await auth.api.getSession({ headers: await headers(), }); @@ -59,7 +56,6 @@ const getTasks = async (statusParam?: string) => { // Filter by Status (using passed argument) if (typeof statusParam === 'string' && statusParam in TaskStatus) { whereClause.status = statusParam as TaskStatus; - console.log(`Filtering by status: ${whereClause.status}`); } const tasks = await db.task.findMany({ @@ -70,7 +66,6 @@ const getTasks = async (statusParam?: string) => { }; const getMembersWithMetadata = async () => { - console.log('Fetching members...'); const session = await auth.api.getSession({ headers: await headers(), }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts new file mode 100644 index 000000000..51ef230c3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts @@ -0,0 +1,85 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import type { ActionResponse } from '@/actions/types'; +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +const deleteVendorSchema = z.object({ + vendorId: z.string(), +}); + +export const deleteVendor = authActionClient + .metadata({ + name: 'delete-vendor', + track: { + event: 'delete_vendor', + channel: 'organization', + }, + }) + .inputSchema(deleteVendorSchema) + .action(async ({ parsedInput, ctx }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: 'User does not have an active organization', + }; + } + + const { vendorId } = parsedInput; + + try { + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: ctx.session.activeOrganizationId, + userId: ctx.user.id, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + return { + success: false, + error: "You don't have permission to delete vendors.", + }; + } + + // Verify the vendor exists within the user's organization + const targetVendor = await db.vendor.findFirst({ + where: { + id: vendorId, + organizationId: ctx.session.activeOrganizationId, + }, + }); + + if (!targetVendor) { + return { + success: false, + error: 'Vendor not found in this organization.', + }; + } + + await db.vendor.delete({ + where: { + id: vendorId, + }, + }); + + // Revalidate the path to refresh the data on the vendors page + revalidatePath(`/${ctx.session.activeOrganizationId}/vendors`); + + return { + success: true, + data: { deleted: true }, + }; + } catch (error) { + console.error('Error deleting vendor:', error); + return { + success: false, + error: 'Failed to delete the vendor. Please try again.', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx index a5adb287f..8e81d6ff4 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorColumns.tsx @@ -6,6 +6,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { UserIcon } from 'lucide-react'; import Link from 'next/link'; import type { GetVendorsResult } from '../data/queries'; +import { VendorDeleteCell } from './VendorDeleteCell'; type VendorRow = GetVendorsResult['data'][number]; @@ -125,4 +126,12 @@ export const columns: ColumnDef[] = [ variant: 'select', }, }, + { + id: 'delete-vendor', + cell: ({ row }) => { + return ; + }, + enableSorting: false, + enableHiding: false, + }, ]; diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorDeleteCell.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorDeleteCell.tsx new file mode 100644 index 000000000..d40c9f6d6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/components/VendorDeleteCell.tsx @@ -0,0 +1,78 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@comp/ui/alert-dialog'; +import { Button } from '@comp/ui/button'; +import { Trash2 } from 'lucide-react'; +import * as React from 'react'; +import { toast } from 'sonner'; +import { deleteVendor } from '../actions/deleteVendor'; +import type { GetVendorsResult } from '../data/queries'; + +type VendorRow = GetVendorsResult['data'][number]; + +interface VendorDeleteCellProps { + vendor: VendorRow; +} + +export const VendorDeleteCell: React.FC = ({ vendor }) => { + const [isRemoveAlertOpen, setIsRemoveAlertOpen] = React.useState(false); + const [isDeleting, setIsDeleting] = React.useState(false); + + const handleDeleteClick = async (event: React.MouseEvent) => { + event.stopPropagation(); + setIsDeleting(true); + + const response = await deleteVendor({ vendorId: vendor.id }); + + if (response?.data?.success) { + toast.success(`Vendor "${vendor.name}" has been deleted.`); + setIsRemoveAlertOpen(false); + } else { + toast.error(String(response?.data?.error) || 'Failed to delete vendor.'); + } + + setIsDeleting(false); + }; + + return ( + <> +
+ +
+ + e.stopPropagation()}> + + Delete Vendor + + Are you sure you want to delete {vendor.name}? This action cannot be + undone. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + + ); +}; diff --git a/apps/app/src/components/organization-switcher.tsx b/apps/app/src/components/organization-switcher.tsx index f77315f82..b5bea0042 100644 --- a/apps/app/src/components/organization-switcher.tsx +++ b/apps/app/src/components/organization-switcher.tsx @@ -13,12 +13,13 @@ import { CommandSeparator, } from '@comp/ui/command'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@comp/ui/dialog'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import type { Organization } from '@db'; import { Check, ChevronsUpDown, Loader2, Plus, Search } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import { useRouter } from 'next/navigation'; import { useQueryState } from 'nuqs'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; interface OrganizationSwitcherProps { organizations: Organization[]; @@ -88,6 +89,27 @@ export function OrganizationSwitcher({ const router = useRouter(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [pendingOrgId, setPendingOrgId] = useState(null); + const [sortOrder, setSortOrder] = useState('alphabetical'); + + useEffect(() => { + const savedSortOrder = localStorage.getItem('org-sort-order'); + if (savedSortOrder) { + setSortOrder(savedSortOrder); + } + }, []); + + useEffect(() => { + localStorage.setItem('org-sort-order', sortOrder); + }, [sortOrder]); + + const sortedOrganizations = [...organizations].sort((a, b) => { + if (sortOrder === 'alphabetical') { + return (a.name).localeCompare(b.name); + } else if (sortOrder === 'recent') { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + return 0; + }); const [showOrganizationSwitcher, setShowOrganizationSwitcher] = useQueryState( 'showOrganizationSwitcher', @@ -177,10 +199,21 @@ export function OrganizationSwitcher({ className="placeholder:text-muted-foreground flex h-11 w-full rounded-md border-0 bg-transparent py-3 text-sm outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50" />
+
+ +
No results found - {organizations.map((org) => ( + {sortedOrganizations.map((org) => (