From 4594a264a1d09965048b1b8870fea945dc71512a Mon Sep 17 00:00:00 2001 From: Potenz Date: Wed, 28 Jan 2026 02:25:18 +0530 Subject: [PATCH] feat(staff column order in aptmpt page) --- apps/Backend/src/routes/staffs.ts | 36 ++--- apps/Backend/src/storage/staff-storage.ts | 135 ++++++++++++++---- .../src/components/staffs/staff-form.tsx | 28 +++- apps/Frontend/src/pages/appointments-page.tsx | 75 +++++----- apps/Frontend/src/pages/settings-page.tsx | 10 +- packages/db/prisma/schema.prisma | 2 +- packages/db/types/staff-types.ts | 42 +++++- 7 files changed, 244 insertions(+), 84 deletions(-) diff --git a/apps/Backend/src/routes/staffs.ts b/apps/Backend/src/routes/staffs.ts index ae74855..83aa3f9 100644 --- a/apps/Backend/src/routes/staffs.ts +++ b/apps/Backend/src/routes/staffs.ts @@ -1,15 +1,12 @@ import { Router } from "express"; import type { Request, Response } from "express"; import { storage } from "../storage"; -import { z } from "zod"; -import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; - -type Staff = z.infer; - -const staffCreateSchema = StaffUncheckedCreateInputObjectSchema; -const staffUpdateSchema = ( - StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject -).partial(); +import { + StaffCreateBody, + StaffCreateInput, + staffCreateSchema, + staffUpdateSchema, +} from "@repo/db/types"; const router = Router(); @@ -17,12 +14,14 @@ router.post("/", async (req: Request, res: Response): Promise => { try { const userId = req.user!.id; // from auth middleware - const validatedData = staffCreateSchema.parse({ - ...req.body, + const body = staffCreateSchema.parse(req.body) as StaffCreateBody; + + const data: StaffCreateInput = { + ...body, userId, - }); - - const newStaff = await storage.createStaff(validatedData); + }; + + const newStaff = await storage.createStaff(data); res.status(200).json(newStaff); } catch (error) { console.error("Failed to create staff:", error); @@ -52,12 +51,17 @@ router.put("/:id", async (req: Request, res: Response): Promise => { const validatedData = staffUpdateSchema.parse(req.body); const updatedStaff = await storage.updateStaff( parsedStaffId, - validatedData + validatedData, ); if (!updatedStaff) return res.status(404).send("Staff not found"); res.json(updatedStaff); - } catch (error) { + } catch (error: any) { + if (error.message?.includes("displayOrder")) { + return res.status(400).json({ + message: error.message, + }); + } console.error("Failed to update staff:", error); res.status(500).send("Failed to update staff"); } diff --git a/apps/Backend/src/storage/staff-storage.ts b/apps/Backend/src/storage/staff-storage.ts index 57ec3a3..d324d4a 100644 --- a/apps/Backend/src/storage/staff-storage.ts +++ b/apps/Backend/src/storage/staff-storage.ts @@ -1,11 +1,14 @@ -import { Staff } from "@repo/db/types"; +import { Staff, StaffCreateInput, StaffUpdateInput } from "@repo/db/types"; import { prisma as db } from "@repo/db/client"; export interface IStorage { getStaff(id: number): Promise; getAllStaff(): Promise; - createStaff(staff: Staff): Promise; - updateStaff(id: number, updates: Partial): Promise; + createStaff(staff: StaffCreateInput): Promise; + updateStaff( + id: number, + updates: StaffUpdateInput, + ): Promise; deleteStaff(id: number): Promise; countAppointmentsByStaffId(staffId: number): Promise; countClaimsByStaffId(staffId: number): Promise; @@ -19,38 +22,122 @@ export const staffStorage: IStorage = { }, async getAllStaff(): Promise { - const staff = await db.staff.findMany(); - return staff; - }, - - async createStaff(staff: Staff): Promise { - const createdStaff = await db.staff.create({ - data: staff, + return db.staff.findMany({ + orderBy: { displayOrder: "asc" }, }); - return createdStaff; }, + async createStaff(staff: StaffCreateInput): Promise { + const max = await db.staff.aggregate({ + where: { + userId: staff.userId, + displayOrder: { gt: 0 }, + }, + _max: { displayOrder: true }, + }); + + return db.staff.create({ + data: { + ...staff, + displayOrder: (max._max.displayOrder ?? 0) + 1, + }, + }); + }, + async updateStaff( id: number, - updates: Partial + updates: StaffUpdateInput, ): Promise { - const updatedStaff = await db.staff.update({ - where: { id }, - data: updates, + return db.$transaction(async (tx: any) => { + const staff = await tx.staff.findUnique({ where: { id } }); + if (!staff) return undefined; + + const { userId, displayOrder: oldOrder } = staff; + const newOrder = updates.displayOrder; + + if (newOrder === undefined || newOrder === oldOrder) { + return tx.staff.update({ + where: { id }, + data: updates, + }); + } + + const totalStaff = await tx.staff.count({ where: { userId } }); + + if (newOrder < 1 || newOrder > totalStaff) { + throw new Error(`displayOrder must be between 1 and ${totalStaff}`); + } + + const occupyingStaff = await tx.staff.findFirst({ + where: { + userId, + displayOrder: newOrder, + }, + }); + + // CASE 1: staff already had a slot → SWAP + if (oldOrder && oldOrder > 0 && occupyingStaff) { + await tx.staff.update({ + where: { id: occupyingStaff.id }, + data: { displayOrder: oldOrder }, + }); + } + + // CASE 2: first-time assignment (oldOrder = 0) + if ((!oldOrder || oldOrder === 0) && occupyingStaff) { + // find first free slot + const usedOrders = await tx.staff.findMany({ + where: { + userId, + displayOrder: { gt: 0 }, + }, + select: { displayOrder: true }, + orderBy: { displayOrder: "asc" }, + }); + + const usedSet = new Set(usedOrders.map((s: any) => s.displayOrder)); + let freeSlot = 1; + while (usedSet.has(freeSlot)) freeSlot++; + + await tx.staff.update({ + where: { id: occupyingStaff.id }, + data: { displayOrder: freeSlot }, + }); + } + + return tx.staff.update({ + where: { id }, + data: { + ...updates, + displayOrder: newOrder, + }, + }); }); - return updatedStaff ?? undefined; }, async deleteStaff(id: number): Promise { - try { - await db.staff.delete({ where: { id } }); - return true; - } catch (error) { - console.error("Error deleting staff:", error); - return false; - } - }, + return db.$transaction(async (tx: any) => { + const staff = await tx.staff.findUnique({ where: { id } }); + if (!staff) return false; + const { userId, displayOrder } = staff; + + await tx.staff.delete({ where: { id } }); + + // Shift left to remove gap + await tx.staff.updateMany({ + where: { + userId, + displayOrder: { gt: displayOrder }, + }, + data: { + displayOrder: { decrement: 1 }, + }, + }); + + return true; + }); + }, async countAppointmentsByStaffId(staffId: number): Promise { return await db.appointment.count({ where: { staffId } }); }, diff --git a/apps/Frontend/src/components/staffs/staff-form.tsx b/apps/Frontend/src/components/staffs/staff-form.tsx index 0a7fb27..ea7ec46 100644 --- a/apps/Frontend/src/components/staffs/staff-form.tsx +++ b/apps/Frontend/src/components/staffs/staff-form.tsx @@ -1,9 +1,9 @@ -import { Staff } from "@repo/db/types"; +import { Staff, StaffFormData } from "@repo/db/types"; import React, { useState, useEffect } from "react"; interface StaffFormProps { initialData?: Partial; - onSubmit: (data: Omit) => void; + onSubmit: (data: StaffFormData) => void; onCancel: () => void; isLoading?: boolean; } @@ -21,6 +21,8 @@ export function StaffForm({ const [hasTypedRole, setHasTypedRole] = useState(false); + const [displayOrder, setDisplayOrder] = useState(""); + // Set initial values once on mount useEffect(() => { if (initialData) { @@ -28,6 +30,9 @@ export function StaffForm({ if (initialData.email) setEmail(initialData.email); if (initialData.role) setRole(initialData.role); if (initialData.phone) setPhone(initialData.phone); + if (initialData?.displayOrder !== undefined) { + setDisplayOrder(initialData.displayOrder); + } } }, []); // run once only @@ -43,6 +48,7 @@ export function StaffForm({ email: email.trim() || undefined, role: role.trim(), phone: phone.trim() || undefined, + displayOrder: displayOrder === "" ? undefined : displayOrder, }); }; @@ -95,6 +101,24 @@ export function StaffForm({ /> +
+ + + setDisplayOrder(e.target.value === "" ? "" : Number(e.target.value)) + } + disabled={isLoading} + /> +

+ Lower number appears first in appointment schedule +

+
+
(null); const [proceduresPatientId, setProceduresPatientId] = useState( - null + null, ); const [proceduresPatient, setProceduresPatient] = useState( - null + null, ); const [calendarOpen, setCalendarOpen] = useState(false); @@ -117,7 +115,7 @@ export default function AppointmentsPage() { }>({ open: false }); const dispatch = useAppDispatch(); const batchTask = useAppSelector( - (state) => state.seleniumEligibilityBatchCheckTask + (state) => state.seleniumEligibilityBatchCheckTask, ); const [isCheckingAllElig, setIsCheckingAllElig] = useState(false); const [processedAppointmentIds, setProcessedAppointmentIds] = useState< @@ -153,7 +151,7 @@ export default function AppointmentsPage() { queryFn: async () => { const res = await apiRequest( "GET", - `/api/appointments/day?date=${formattedSelectedDate}` + `/api/appointments/day?date=${formattedSelectedDate}`, ); if (!res.ok) { throw new Error("Failed to load appointments for date"); @@ -168,7 +166,7 @@ export default function AppointmentsPage() { const patientsFromDay = dayPayload.patients ?? []; // Staff memebers - const { data: staffMembersRaw = [] as Staff[] } = useQuery({ + const { data: staffMembersRaw = [] as DBStaff[] } = useQuery({ queryKey: ["/api/staffs/"], queryFn: async () => { const res = await apiRequest("GET", "/api/staffs/"); @@ -186,11 +184,18 @@ export default function AppointmentsPage() { "bg-orange-500", // softer warm orange ]; - // Assign colors cycling through the list - const staffMembers = staffMembersRaw.map((staff, index) => ({ - ...staff, + // Assign colors cycling through the list, and order them by display order for the page column. - color: colors[index % colors.length] || "bg-gray-400", + const orderedStaff = staffMembersRaw.filter( + (s): s is DBStaff & { displayOrder: number } => + typeof s.displayOrder === "number" && s.displayOrder > 0, + ); + + orderedStaff.sort((a, b) => a.displayOrder - b.displayOrder); + + const staffMembers: StaffWithColor[] = orderedStaff.map((staff, index) => ({ + ...staff, + color: colors[index % colors.length] ?? "bg-gray-400", })); // Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments @@ -245,13 +250,13 @@ export default function AppointmentsPage() { }; sessionStorage.setItem( "newAppointmentData", - JSON.stringify(newAppointmentData) + JSON.stringify(newAppointmentData), ); } catch (err) { // If sessionStorage parsing fails, overwrite with a fresh object sessionStorage.setItem( "newAppointmentData", - JSON.stringify({ patientId: patientId }) + JSON.stringify({ patientId: patientId }), ); } @@ -272,7 +277,7 @@ export default function AppointmentsPage() { const res = await apiRequest( "POST", "/api/appointments/upsert", - appointment + appointment, ); return await res.json(); }, @@ -308,7 +313,7 @@ export default function AppointmentsPage() { const res = await apiRequest( "PUT", `/api/appointments/${id}`, - appointment + appointment, ); return await res.json(); }, @@ -359,7 +364,7 @@ export default function AppointmentsPage() { // Handle appointment submission (create or update) const handleAppointmentSubmit = ( - appointmentData: InsertAppointment | UpdateAppointment + appointmentData: InsertAppointment | UpdateAppointment, ) => { // Converts local date to exact UTC date with no offset issues const rawDate = parseLocalDate(appointmentData.date); @@ -479,7 +484,7 @@ export default function AppointmentsPage() { // Handle creating a new appointment at a specific time slot and for a specific staff member const handleCreateAppointmentAtSlot = ( timeSlot: TimeSlot, - staffId: number + staffId: number, ) => { // Calculate end time (30 minutes after start time) const startHour = parseInt(timeSlot.time.split(":")[0] as string); @@ -532,7 +537,7 @@ export default function AppointmentsPage() { try { sessionStorage.setItem( "newAppointmentData", - JSON.stringify(mergedAppointment) + JSON.stringify(mergedAppointment), ); } catch (e) { // ignore sessionStorage write failures @@ -550,7 +555,7 @@ export default function AppointmentsPage() { const handleMoveAppointment = ( appointmentId: number, newTimeSlot: TimeSlot, - newStaffId: number + newStaffId: number, ) => { const appointment = appointments.find((a) => a.id === appointmentId); if (!appointment) return; @@ -603,7 +608,7 @@ export default function AppointmentsPage() { staff, }: { appointment: ScheduledAppointment; - staff: Staff; + staff: StaffWithColor; }) { const [{ isDragging }, drag] = useDrag(() => ({ type: ItemTypes.APPOINTMENT, @@ -624,7 +629,7 @@ export default function AppointmentsPage() { // Only allow edit on click if we're not dragging if (!isDragging) { const fullAppointment = appointments.find( - (a) => a.id === appointment.id + (a) => a.id === appointment.id, ); if (fullAppointment) { e.stopPropagation(); @@ -659,7 +664,7 @@ export default function AppointmentsPage() { timeSlot: TimeSlot; staffId: number; appointment: ScheduledAppointment | undefined; - staff: Staff; + staff: StaffWithColor; }) { const [{ isOver, canDrop }, drop] = useDrop(() => ({ accept: ItemTypes.APPOINTMENT, @@ -700,13 +705,13 @@ export default function AppointmentsPage() { // appointment page — update these handlers const handleCheckEligibility = (appointmentId: number) => { setLocation( - `/insurance-status?appointmentId=${appointmentId}&action=eligibility` + `/insurance-status?appointmentId=${appointmentId}&action=eligibility`, ); }; const handleCheckClaimStatus = (appointmentId: number) => { setLocation( - `/insurance-status?appointmentId=${appointmentId}&action=claim` + `/insurance-status?appointmentId=${appointmentId}&action=claim`, ); }; @@ -720,7 +725,7 @@ export default function AppointmentsPage() { const handleChartPlan = (appointmentId: number) => { console.log( - `Viewing chart/treatment plan for appointment: ${appointmentId}` + `Viewing chart/treatment plan for appointment: ${appointmentId}`, ); }; @@ -745,7 +750,7 @@ export default function AppointmentsPage() { setTaskStatus({ status: "pending", message: `Checking eligibility for appointments on ${dateParam}...`, - }) + }), ); setIsCheckingAllElig(true); @@ -755,7 +760,7 @@ export default function AppointmentsPage() { const res = await apiRequest( "POST", `/api/insurance-status/appointments/check-all-eligibilities?date=${dateParam}`, - {} + {}, ); // read body for all cases so we can show per-appointment info @@ -773,7 +778,7 @@ export default function AppointmentsPage() { setTaskStatus({ status: "error", message: `Batch eligibility failed: ${errMsg}`, - }) + }), ); toast({ title: "Batch check failed", @@ -851,7 +856,7 @@ export default function AppointmentsPage() { setTaskStatus({ status: skippedCount > 0 ? "error" : "success", message: `Batch processed ${results.length} appointments — success: ${successCount}, warnings: ${warningCount}, skipped: ${skippedCount}.`, - }) + }), ); // also show final toast summary @@ -866,7 +871,7 @@ export default function AppointmentsPage() { setTaskStatus({ status: "error", message: `Batch eligibility error: ${err?.message ?? String(err)}`, - }) + }), ); toast({ title: "Batch check failed", @@ -970,7 +975,7 @@ export default function AppointmentsPage() { { const fullAppointment = appointments.find( - (a) => a.id === props.appointmentId + (a) => a.id === props.appointmentId, ); if (fullAppointment) { handleEditAppointment(fullAppointment); @@ -1148,7 +1153,7 @@ export default function AppointmentsPage() { staffId={Number(staff.id)} appointment={getAppointmentAtSlot( timeSlot, - Number(staff.id) + Number(staff.id), )} staff={staff} /> diff --git a/apps/Frontend/src/pages/settings-page.tsx b/apps/Frontend/src/pages/settings-page.tsx index fa4fbf3..440bdfd 100644 --- a/apps/Frontend/src/pages/settings-page.tsx +++ b/apps/Frontend/src/pages/settings-page.tsx @@ -8,7 +8,7 @@ import { StaffForm } from "@/components/staffs/staff-form"; import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; import { CredentialTable } from "@/components/settings/insuranceCredTable"; import { useAuth } from "@/hooks/use-auth"; -import { Staff } from "@repo/db/types"; +import { Staff, StaffFormData } from "@repo/db/types"; import { NpiProviderTable } from "@/components/settings/npiProviderTable"; export default function SettingsPage() { @@ -44,9 +44,9 @@ export default function SettingsPage() { const addStaffMutate = useMutation< Staff, // Return type Error, // Error type - Omit // Variables + StaffFormData >({ - mutationFn: async (newStaff: Omit) => { + mutationFn: async (newStaff: StaffFormData) => { const res = await apiRequest("POST", "/api/staffs/", newStaff); if (!res.ok) { const errorData = await res.json().catch(() => null); @@ -75,7 +75,7 @@ export default function SettingsPage() { const updateStaffMutate = useMutation< Staff, Error, - { id: number; updatedFields: Partial } + { id: number; updatedFields: Partial } >({ mutationFn: async ({ id, @@ -157,7 +157,7 @@ export default function SettingsPage() { }; // Handle form submit for Add or Edit - const handleFormSubmit = (formData: Omit) => { + const handleFormSubmit = (formData: StaffFormData) => { if (editingStaff) { // Editing existing staff if (editingStaff.id === undefined) { diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f332bb4..de71118 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -110,6 +110,7 @@ model Staff { role String // e.g., "Dentist", "Hygienist", "Assistant" phone String? createdAt DateTime @default(now()) + displayOrder Int @default(0) user User? @relation(fields: [userId], references: [id], onDelete: Cascade) appointments Appointment[] claims Claim[] @relation("ClaimStaff") @@ -128,7 +129,6 @@ model NpiProvider { @@index([userId]) } - enum ProcedureSource { COMBO MANUAL diff --git a/packages/db/types/staff-types.ts b/packages/db/types/staff-types.ts index 4c19f3f..19ce026 100644 --- a/packages/db/types/staff-types.ts +++ b/packages/db/types/staff-types.ts @@ -1,4 +1,44 @@ import { StaffUncheckedCreateInputObjectSchema } from "@repo/db/usedSchemas"; -import {z} from "zod"; +import { z } from "zod"; export type Staff = z.infer; + +export const staffCreateSchema = ( + StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject +).omit({ + id: true, + createdAt: true, + displayOrder: true, + userId: true, +}); + +export const staffUpdateSchema = ( + StaffUncheckedCreateInputObjectSchema as unknown as z.ZodObject +) + .partial() + .omit({ + id: true, + createdAt: true, + userId: true, + }); + +export type StaffFormData = { + name: string; + email?: string; + role: string; + phone?: string; + displayOrder?: number; +}; + +export type StaffCreateInput = z.infer< + typeof StaffUncheckedCreateInputObjectSchema +>; + +export type StaffCreateBody = Omit< + StaffCreateInput, + "id" | "createdAt" | "userId" | "displayOrder" +>; + +export type StaffUpdateInput = Partial< + Omit +>;