@@ -0,0 +1,781 @@
import { useState , useEffect , useRef , useCallback } from "react" ;
import { useQuery , useMutation } from "@tanstack/react-query" ;
import { apiRequest , queryClient } from "@/lib/queryClient" ;
import { Card , CardContent } from "@/components/ui/card" ;
import { useToast } from "@/hooks/use-toast" ;
import { Trash2 , Plus , X , Pencil } from "lucide-react" ;
// ── Types ─────────────────────────────────────────────────────────────────────
type DoctorColumn = "A" | "B" | "C" ;
interface ProcedureEntry {
id : string ;
code : string ;
description : string ;
durationMin : number ;
}
interface DoctorSlot {
id : string ;
column : DoctorColumn ;
startTime : string ; // "HH:MM"
endTime : string ; // "HH:MM"
label : string ;
}
interface HygienistSlot {
id : string ;
description : string ;
durationMin : number ;
}
interface TimeslotData {
procedures : ProcedureEntry [ ] ;
doctorSlots : DoctorSlot [ ] ;
hygienistSlots : HygienistSlot [ ] ;
}
// ── Time grid helpers ─────────────────────────────────────────────────────────
function buildTimeSlots() {
const slots : { time : string ; display : string } [ ] = [ ] ;
for ( let h = 8 ; h <= 21 ; h ++ ) {
for ( let m = 0 ; m < 60 ; m += 15 ) {
if ( h === 21 && m > 0 ) continue ;
const pad = ( n : number ) = > n . toString ( ) . padStart ( 2 , "0" ) ;
const time = ` ${ pad ( h ) } : ${ pad ( m ) } ` ;
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h ;
const period = h >= 12 ? "PM" : "AM" ;
const display = ` ${ h12 } : ${ pad ( m ) } ${ period } ` ;
slots . push ( { time , display } ) ;
}
}
return slots ;
}
const TIME_SLOTS = buildTimeSlots ( ) ;
function timeToIdx ( time : string ) : number {
const [ h , m ] = time . split ( ":" ) . map ( Number ) ;
return ( h - 8 ) * 4 + Math . floor ( m / 15 ) ;
}
function idxToTime ( idx : number ) : string {
const totalMin = idx * 15 + 8 * 60 ;
const h = Math . floor ( totalMin / 60 ) ;
const m = totalMin % 60 ;
return ` ${ h . toString ( ) . padStart ( 2 , "0" ) } : ${ m . toString ( ) . padStart ( 2 , "0" ) } ` ;
}
function uid() {
return Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) ;
}
const DURATION_OPTIONS = [ 15 , 30 , 45 , 60 , 75 , 90 , 120 ] ;
const COL_COLORS : Record < DoctorColumn , string > = {
A : "bg-sky-500" ,
B : "bg-teal-500" ,
C : "bg-indigo-500" ,
} ;
const COL_LIGHT : Record < DoctorColumn , string > = {
A : "bg-sky-100 border-sky-300 text-sky-800" ,
B : "bg-teal-100 border-teal-300 text-teal-800" ,
C : "bg-indigo-100 border-indigo-300 text-indigo-800" ,
} ;
// ── Main component ────────────────────────────────────────────────────────────
export function ProcedureTimeslotCard() {
const { toast } = useToast ( ) ;
// ── Remote data ─────────────────────────────────────────────────
const { data : remote , isLoading } = useQuery < { data : TimeslotData } | null > ( {
queryKey : [ "/api/procedure-timeslot" ] ,
queryFn : async ( ) = > {
const res = await apiRequest ( "GET" , "/api/procedure-timeslot" ) ;
if ( ! res . ok ) throw new Error ( "Failed to load" ) ;
return res . json ( ) ;
} ,
} ) ;
const saveMutation = useMutation ( {
mutationFn : async ( payload : TimeslotData ) = > {
const res = await apiRequest ( "PUT" , "/api/procedure-timeslot" , { data : payload } ) ;
if ( ! res . ok ) throw new Error ( "Failed to save" ) ;
return res . json ( ) ;
} ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( { queryKey : [ "/api/procedure-timeslot" ] } ) ;
toast ( { title : "Saved" , description : "Settings saved successfully." } ) ;
} ,
onError : ( ) = > toast ( { title : "Error" , description : "Failed to save." , variant : "destructive" } ) ,
} ) ;
// ── Section 1 state: Procedure durations ─────────────────────────
const [ procedures , setProcedures ] = useState < ProcedureEntry [ ] > ( [ ] ) ;
const [ newCode , setNewCode ] = useState ( "" ) ;
const [ newDesc , setNewDesc ] = useState ( "" ) ;
const [ newDuration , setNewDuration ] = useState ( 30 ) ;
// ── Section 2 state: Doctor time slots ───────────────────────────
const [ doctorSlots , setDoctorSlots ] = useState < DoctorSlot [ ] > ( [ ] ) ;
// Drag selection state
const [ dragCol , setDragCol ] = useState < DoctorColumn | null > ( null ) ;
const [ dragStartIdx , setDragStartIdx ] = useState < number | null > ( null ) ;
const [ dragEndIdx , setDragEndIdx ] = useState < number | null > ( null ) ;
const mouseDownRef = useRef ( false ) ;
// Pending slot confirmation
const [ pendingSlot , setPendingSlot ] = useState < { col : DoctorColumn ; startIdx : number ; endIdx : number } | null > ( null ) ;
const [ pendingLabel , setPendingLabel ] = useState ( "" ) ;
// Edit existing slot
const [ editingSlot , setEditingSlot ] = useState < DoctorSlot | null > ( null ) ;
const [ editLabel , setEditLabel ] = useState ( "" ) ;
const [ editStart , setEditStart ] = useState ( "" ) ;
const [ editEnd , setEditEnd ] = useState ( "" ) ;
const [ editCol , setEditCol ] = useState < DoctorColumn > ( "A" ) ;
// ── Section 3 state: Hygienist slots ─────────────────────────────
const [ hygienistSlots , setHygienistSlots ] = useState < HygienistSlot [ ] > ( [ ] ) ;
const [ newHygDesc , setNewHygDesc ] = useState ( "" ) ;
const [ newHygDuration , setNewHygDuration ] = useState ( 30 ) ;
// Populate from remote when loaded
useEffect ( ( ) = > {
if ( ! remote ? . data ) return ;
const d = remote . data ;
setProcedures ( d . procedures ? ? [ ] ) ;
setDoctorSlots ( d . doctorSlots ? ? [ ] ) ;
setHygienistSlots ( d . hygienistSlots ? ? [ ] ) ;
} , [ remote ] ) ;
// Global mouseup handler to end drag regardless of cursor position
useEffect ( ( ) = > {
const onMouseUp = ( ) = > {
if ( ! mouseDownRef . current ) return ;
mouseDownRef . current = false ;
if ( dragCol !== null && dragStartIdx !== null && dragEndIdx !== null ) {
const s = Math . min ( dragStartIdx , dragEndIdx ) ;
const e = Math . max ( dragStartIdx , dragEndIdx ) ;
if ( e > s ) {
setPendingSlot ( { col : dragCol , startIdx : s , endIdx : e } ) ;
setPendingLabel ( "" ) ;
}
}
setDragCol ( null ) ;
setDragStartIdx ( null ) ;
setDragEndIdx ( null ) ;
} ;
window . addEventListener ( "mouseup" , onMouseUp ) ;
return ( ) = > window . removeEventListener ( "mouseup" , onMouseUp ) ;
} , [ dragCol , dragStartIdx , dragEndIdx ] ) ;
// ── Drag handlers ────────────────────────────────────────────────
const handleCellMouseDown = useCallback ( ( col : DoctorColumn , idx : number ) = > {
mouseDownRef . current = true ;
setDragCol ( col ) ;
setDragStartIdx ( idx ) ;
setDragEndIdx ( idx ) ;
} , [ ] ) ;
const handleCellMouseEnter = useCallback ( ( col : DoctorColumn , idx : number ) = > {
if ( ! mouseDownRef . current || dragCol !== col ) return ;
setDragEndIdx ( idx ) ;
} , [ dragCol ] ) ;
const isDragHighlighted = ( col : DoctorColumn , idx : number ) = > {
if ( dragCol !== col || dragStartIdx === null || dragEndIdx === null ) return false ;
const s = Math . min ( dragStartIdx , dragEndIdx ) ;
const e = Math . max ( dragStartIdx , dragEndIdx ) ;
return idx >= s && idx <= e ;
} ;
// ── Doctor slot helpers ──────────────────────────────────────────
const getSlotOccupancy = ( col : DoctorColumn , rowIdx : number ) = > {
for ( const slot of doctorSlots ) {
if ( slot . column !== col ) continue ;
const startIdx = timeToIdx ( slot . startTime ) ;
const endIdx = timeToIdx ( slot . endTime ) ;
if ( rowIdx === startIdx ) return { slot , isStart : true , rowSpan : endIdx - startIdx } ;
if ( rowIdx > startIdx && rowIdx < endIdx ) return { slot , isStart : false , rowSpan : 0 } ;
}
return null ;
} ;
const confirmPendingSlot = ( ) = > {
if ( ! pendingSlot ) return ;
// Check for overlap with existing slots in same column
const newStart = idxToTime ( pendingSlot . startIdx ) ;
const newEnd = idxToTime ( pendingSlot . endIdx ) ;
const overlap = doctorSlots . some ( ( s ) = > {
if ( s . column !== pendingSlot . col ) return false ;
return ! ( newEnd <= s . startTime || newStart >= s . endTime ) ;
} ) ;
if ( overlap ) {
toast ( { title : "Overlap" , description : "This time range overlaps an existing slot." , variant : "destructive" } ) ;
setPendingSlot ( null ) ;
return ;
}
setDoctorSlots ( ( prev ) = > [
. . . prev ,
{ id : uid ( ) , column : pendingSlot.col , startTime : newStart , endTime : newEnd , label : pendingLabel.trim ( ) } ,
] ) ;
setPendingSlot ( null ) ;
setPendingLabel ( "" ) ;
} ;
const deleteDocSlot = ( id : string ) = > setDoctorSlots ( ( prev ) = > prev . filter ( ( s ) = > s . id !== id ) ) ;
const openEditSlot = ( slot : DoctorSlot ) = > {
setEditingSlot ( slot ) ;
setEditLabel ( slot . label ) ;
setEditStart ( slot . startTime ) ;
setEditEnd ( slot . endTime ) ;
setEditCol ( slot . column ) ;
setPendingSlot ( null ) ;
} ;
const saveEditSlot = ( ) = > {
if ( ! editingSlot ) return ;
if ( editStart >= editEnd ) {
toast ( { title : "Invalid time range" , description : "End time must be after start time." , variant : "destructive" } ) ;
return ;
}
const overlap = doctorSlots . some ( ( s ) = > {
if ( s . id === editingSlot . id || s . column !== editCol ) return false ;
return ! ( editEnd <= s . startTime || editStart >= s . endTime ) ;
} ) ;
if ( overlap ) {
toast ( { title : "Overlap" , description : "This time range overlaps an existing slot." , variant : "destructive" } ) ;
return ;
}
setDoctorSlots ( ( prev ) = >
prev . map ( ( s ) = >
s . id === editingSlot . id
? { . . . s , column : editCol , startTime : editStart , endTime : editEnd , label : editLabel.trim ( ) }
: s
)
) ;
setEditingSlot ( null ) ;
} ;
const confirmDeleteEditSlot = ( ) = > {
if ( ! editingSlot ) return ;
deleteDocSlot ( editingSlot . id ) ;
setEditingSlot ( null ) ;
} ;
// ── Save helpers ─────────────────────────────────────────────────
const buildPayload = ( ) : TimeslotData = > ( { procedures , doctorSlots , hygienistSlots } ) ;
const handleSaveProcedures = ( ) = > saveMutation . mutate ( { . . . buildPayload ( ) , procedures } ) ;
const handleSaveDoctorSlots = ( ) = > saveMutation . mutate ( { . . . buildPayload ( ) , doctorSlots } ) ;
const handleSaveHygienistSlots = ( ) = > saveMutation . mutate ( { . . . buildPayload ( ) , hygienistSlots } ) ;
// ── Section 1 actions ────────────────────────────────────────────
const addProcedure = ( ) = > {
if ( ! newCode . trim ( ) ) return ;
setProcedures ( ( prev ) = > [ . . . prev , { id : uid ( ) , code : newCode.trim ( ) . toUpperCase ( ) , description : newDesc.trim ( ) , durationMin : newDuration } ] ) ;
setNewCode ( "" ) ;
setNewDesc ( "" ) ;
setNewDuration ( 30 ) ;
} ;
const deleteProcedure = ( id : string ) = > setProcedures ( ( prev ) = > prev . filter ( ( p ) = > p . id !== id ) ) ;
const updateProcedure = ( id : string , field : keyof ProcedureEntry , value : string | number ) = > {
setProcedures ( ( prev ) = > prev . map ( ( p ) = > ( p . id === id ? { . . . p , [ field ] : value } : p ) ) ) ;
} ;
// ── Section 3 actions ────────────────────────────────────────────
const addHygSlot = ( ) = > {
if ( ! newHygDesc . trim ( ) ) return ;
setHygienistSlots ( ( prev ) = > [ . . . prev , { id : uid ( ) , description : newHygDesc.trim ( ) , durationMin : newHygDuration } ] ) ;
setNewHygDesc ( "" ) ;
setNewHygDuration ( 30 ) ;
} ;
const deleteHygSlot = ( id : string ) = > setHygienistSlots ( ( prev ) = > prev . filter ( ( s ) = > s . id !== id ) ) ;
const updateHygSlot = ( id : string , field : keyof HygienistSlot , value : string | number ) = > {
setHygienistSlots ( ( prev ) = > prev . map ( ( s ) = > ( s . id === id ? { . . . s , [ field ] : value } : s ) ) ) ;
} ;
// ── Render ───────────────────────────────────────────────────────
if ( isLoading ) return < Card > < CardContent className = "py-10 text-center text-gray-400" > Loading . . . < / CardContent > < / Card > ;
return (
< div className = "space-y-6" >
{ /* ── Section 1: Procedure Duration ── */ }
< Card >
< CardContent className = "py-6 space-y-4" >
< div >
< h2 className = "text-lg font-semibold text-gray-800" > Procedure Duration < / h2 >
< p className = "text-sm text-gray-500 mt-0.5" > Set the standard time needed for common dental procedures by CDT code . < / p >
< / div >
{ /* Existing procedure rows */ }
{ procedures . length > 0 && (
< div className = "border rounded overflow-hidden" >
< table className = "w-full text-sm" >
< thead className = "bg-gray-50 border-b" >
< tr >
< th className = "text-left px-3 py-2 font-medium text-gray-600 w-32" > CDT Code < / th >
< th className = "text-left px-3 py-2 font-medium text-gray-600" > Description < / th >
< th className = "text-left px-3 py-2 font-medium text-gray-600 w-36" > Duration < / th >
< th className = "w-10" / >
< / tr >
< / thead >
< tbody className = "divide-y" >
{ procedures . map ( ( proc ) = > (
< tr key = { proc . id } className = "hover:bg-gray-50" >
< td className = "px-3 py-2" >
< input
type = "text"
value = { proc . code }
onChange = { ( e ) = > updateProcedure ( proc . id , "code" , e . target . value . toUpperCase ( ) ) }
className = "w-full border rounded px-2 py-1 text-sm font-mono uppercase"
placeholder = "D1110"
/ >
< / td >
< td className = "px-3 py-2" >
< input
type = "text"
value = { proc . description }
onChange = { ( e ) = > updateProcedure ( proc . id , "description" , e . target . value ) }
className = "w-full border rounded px-2 py-1 text-sm"
placeholder = "Adult Prophy"
/ >
< / td >
< td className = "px-3 py-2" >
< select
value = { proc . durationMin }
onChange = { ( e ) = > updateProcedure ( proc . id , "durationMin" , Number ( e . target . value ) ) }
className = "border rounded px-2 py-1 text-sm w-full"
>
{ DURATION_OPTIONS . map ( ( d ) = > (
< option key = { d } value = { d } > { d } min < / option >
) ) }
< / select >
< / td >
< td className = "px-2 py-2 text-center" >
< button onClick = { ( ) = > deleteProcedure ( proc . id ) } className = "text-red-400 hover:text-red-600" >
< Trash2 size = { 15 } / >
< / button >
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) }
{ /* Add new procedure row */ }
< div className = "flex gap-2 items-end" >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > CDT Code < / label >
< input
type = "text"
value = { newCode }
onChange = { ( e ) = > setNewCode ( e . target . value . toUpperCase ( ) ) }
onKeyDown = { ( e ) = > e . key === "Enter" && addProcedure ( ) }
className = "border rounded px-2 py-1.5 text-sm font-mono uppercase w-28"
placeholder = "D1110"
/ >
< / div >
< div className = "flex flex-col gap-1 flex-1" >
< label className = "text-xs font-medium text-gray-600" > Description < / label >
< input
type = "text"
value = { newDesc }
onChange = { ( e ) = > setNewDesc ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && addProcedure ( ) }
className = "border rounded px-2 py-1.5 text-sm w-full"
placeholder = "e.g. Adult Prophy"
/ >
< / div >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > Duration < / label >
< select
value = { newDuration }
onChange = { ( e ) = > setNewDuration ( Number ( e . target . value ) ) }
className = "border rounded px-2 py-1.5 text-sm"
>
{ DURATION_OPTIONS . map ( ( d ) = > (
< option key = { d } value = { d } > { d } min < / option >
) ) }
< / select >
< / div >
< button
onClick = { addProcedure }
className = "flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm"
>
< Plus size = { 14 } / > Add
< / button >
< / div >
< div className = "flex justify-end pt-1" >
< button
onClick = { handleSaveProcedures }
disabled = { saveMutation . isPending }
className = "bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
>
{ saveMutation . isPending ? "Saving…" : "Save Procedures" }
< / button >
< / div >
< / CardContent >
< / Card >
{ /* ── Section 2: Doctor Time Slot Settings ── */ }
< Card >
< CardContent className = "py-6 space-y-4" >
< div >
< h2 className = "text-lg font-semibold text-gray-800" > Doctor Time Slot Settings < / h2 >
< p className = "text-sm text-gray-500 mt-0.5" >
Template reference for scheduling — drag to block time ranges in columns A , B , C . This does not affect the main schedule .
< / p >
< div className = "flex gap-4 mt-2" >
{ ( [ "A" , "B" , "C" ] as DoctorColumn [ ] ) . map ( ( col ) = > (
< span key = { col } className = { ` inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${ COL_LIGHT [ col ] } ` } >
< span className = { ` w-2.5 h-2.5 rounded-full ${ COL_COLORS [ col ] } ` } / > Column { col }
< / span >
) ) }
< span className = "text-xs text-gray-400 self-center" > Drag cells to block a time range < / span >
< / div >
< / div >
{ /* Pending slot confirmation dialog */ }
{ pendingSlot && (
< div className = "border border-blue-200 bg-blue-50 rounded-lg p-4 flex flex-col gap-3" >
< p className = "text-sm font-medium text-blue-800" >
New slot in column < strong > { pendingSlot . col } < / strong > : { " " }
{ TIME_SLOTS [ pendingSlot . startIdx ] ? . display } → { TIME_SLOTS [ pendingSlot . endIdx ] ? . display }
< / p >
< div className = "flex gap-2 items-center" >
< input
type = "text"
value = { pendingLabel }
onChange = { ( e ) = > setPendingLabel ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && confirmPendingSlot ( ) }
className = "border rounded px-2 py-1.5 text-sm flex-1"
placeholder = "Label (e.g. Root Canal D3330) — optional"
autoFocus
/ >
< button onClick = { confirmPendingSlot } className = "bg-teal-600 text-white px-3 py-1.5 rounded text-sm hover:bg-teal-700" >
Confirm
< / button >
< button onClick = { ( ) = > setPendingSlot ( null ) } className = "text-gray-500 hover:text-gray-700" >
< X size = { 18 } / >
< / button >
< / div >
< / div >
) }
{ /* Edit existing slot dialog */ }
{ editingSlot && (
< div className = "border border-amber-200 bg-amber-50 rounded-lg p-4 space-y-3" >
< div className = "flex items-center justify-between" >
< p className = "text-sm font-semibold text-amber-800 flex items-center gap-1.5" >
< Pencil size = { 14 } / > Edit Slot
< / p >
< button onClick = { ( ) = > setEditingSlot ( null ) } className = "text-gray-400 hover:text-gray-600" >
< X size = { 16 } / >
< / button >
< / div >
< div className = "grid grid-cols-2 gap-3 sm:grid-cols-4" >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > Column < / label >
< select
value = { editCol }
onChange = { ( e ) = > setEditCol ( e . target . value as DoctorColumn ) }
className = "border rounded px-2 py-1.5 text-sm"
>
{ ( [ "A" , "B" , "C" ] as DoctorColumn [ ] ) . map ( ( c ) = > (
< option key = { c } value = { c } > { c } < / option >
) ) }
< / select >
< / div >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > Start Time < / label >
< select
value = { editStart }
onChange = { ( e ) = > setEditStart ( e . target . value ) }
className = "border rounded px-2 py-1.5 text-sm"
>
{ TIME_SLOTS . slice ( 0 , - 1 ) . map ( ( { time , display } ) = > (
< option key = { time } value = { time } > { display } < / option >
) ) }
< / select >
< / div >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > End Time < / label >
< select
value = { editEnd }
onChange = { ( e ) = > setEditEnd ( e . target . value ) }
className = "border rounded px-2 py-1.5 text-sm"
>
{ TIME_SLOTS . filter ( ( { time } ) = > time > editStart ) . map ( ( { time , display } ) = > (
< option key = { time } value = { time } > { display } < / option >
) ) }
< / select >
< / div >
< div className = "flex flex-col gap-1 sm:col-span-1 col-span-2" >
< label className = "text-xs font-medium text-gray-600" > Label < / label >
< input
type = "text"
value = { editLabel }
onChange = { ( e ) = > setEditLabel ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && saveEditSlot ( ) }
className = "border rounded px-2 py-1.5 text-sm w-full"
placeholder = "e.g. Root Canal D3330"
autoFocus
/ >
< / div >
< / div >
< div className = "flex gap-2 justify-between pt-1" >
< button
onClick = { confirmDeleteEditSlot }
className = "flex items-center gap-1 text-red-600 border border-red-200 bg-red-50 hover:bg-red-100 px-3 py-1.5 rounded text-sm"
>
< Trash2 size = { 13 } / > Delete Slot
< / button >
< div className = "flex gap-2" >
< button onClick = { ( ) = > setEditingSlot ( null ) } className = "border px-3 py-1.5 rounded text-sm text-gray-600 hover:bg-gray-100" >
Cancel
< / button >
< button onClick = { saveEditSlot } className = "bg-teal-600 text-white px-4 py-1.5 rounded text-sm hover:bg-teal-700" >
Save Changes
< / button >
< / div >
< / div >
< / div >
) }
{ /* Time grid */ }
< div className = "border rounded overflow-auto max-h-[520px]" >
< table
className = "w-full text-xs border-collapse select-none"
style = { { tableLayout : "fixed" } }
onMouseLeave = { ( ) = > {
// don't cancel drag on table leave — global mouseup handles it
} }
>
< colgroup >
< col style = { { width : "72px" } } / >
< col / >
< col / >
< col / >
< / colgroup >
< thead className = "sticky top-0 z-10 bg-white border-b" >
< tr >
< th className = "text-left px-2 py-2 font-medium text-gray-500 border-r" > Time < / th >
{ ( [ "A" , "B" , "C" ] as DoctorColumn [ ] ) . map ( ( col ) = > (
< th key = { col } className = { ` py-2 font-semibold text-center border-r last:border-r-0 ${ COL_LIGHT [ col ] } ` } >
{ col }
< / th >
) ) }
< / tr >
< / thead >
< tbody >
{ TIME_SLOTS . map ( ( { time , display } , rowIdx ) = > {
const isHour = time . endsWith ( ":00" ) ;
return (
< tr key = { time } className = { isHour ? "border-t border-gray-300" : "border-t border-gray-100" } >
{ /* Time label */ }
< td className = "px-2 py-0 border-r text-gray-400 whitespace-nowrap" style = { { height : "22px" } } >
{ isHour ? display : "" }
< / td >
{ /* Columns A / B / C */ }
{ ( [ "A" , "B" , "C" ] as DoctorColumn [ ] ) . map ( ( col ) = > {
const occ = getSlotOccupancy ( col , rowIdx ) ;
if ( occ && ! occ . isStart ) return null ; // spanned — skip td
if ( occ && occ . isStart ) {
const isBeingEdited = editingSlot ? . id === occ . slot . id ;
return (
< td
key = { col }
rowSpan = { occ . rowSpan }
className = { ` border-r last:border-r-0 px-1 py-0.5 align-top cursor-pointer ${
isBeingEdited
? "ring-2 ring-inset ring-amber-400 " + COL_LIGHT [ col ]
: COL_LIGHT [ col ]
} ` }
onClick = { ( e ) = > { e . stopPropagation ( ) ; openEditSlot ( occ . slot ) ; } }
>
< div className = "flex items-start justify-between gap-1 h-full" >
< div className = "min-w-0" >
< div className = "font-medium leading-tight break-words text-[11px]" >
{ occ . slot . label || "Slot" }
< / div >
< div className = "text-[10px] opacity-70 mt-0.5 whitespace-nowrap" >
{ TIME_SLOTS [ timeToIdx ( occ . slot . startTime ) ] ? . display } – { TIME_SLOTS [ timeToIdx ( occ . slot . endTime ) ] ? . display }
< / div >
< / div >
< div className = "flex flex-col gap-0.5 flex-shrink-0" >
< button
onMouseDown = { ( e ) = > e . stopPropagation ( ) }
onClick = { ( e ) = > { e . stopPropagation ( ) ; openEditSlot ( occ . slot ) ; } }
className = "text-gray-500 hover:text-amber-600"
title = "Edit"
>
< Pencil size = { 11 } / >
< / button >
< button
onMouseDown = { ( e ) = > e . stopPropagation ( ) }
onClick = { ( e ) = > { e . stopPropagation ( ) ; deleteDocSlot ( occ . slot . id ) ; if ( editingSlot ? . id === occ . slot . id ) setEditingSlot ( null ) ; } }
className = "text-red-400 hover:text-red-600"
title = "Delete"
>
< X size = { 11 } / >
< / button >
< / div >
< / div >
< / td >
) ;
}
// Empty / draggable cell
const highlighted = isDragHighlighted ( col , rowIdx ) ;
return (
< td
key = { col }
className = { ` border-r last:border-r-0 cursor-crosshair transition-colors ${
highlighted ? "bg-blue-200" : "hover:bg-gray-50"
} ` }
onMouseDown = { ( ) = > handleCellMouseDown ( col , rowIdx ) }
onMouseEnter = { ( ) = > handleCellMouseEnter ( col , rowIdx ) }
/ >
) ;
} ) }
< / tr >
) ;
} ) }
< / tbody >
< / table >
< / div >
< div className = "flex justify-end pt-1" >
< button
onClick = { handleSaveDoctorSlots }
disabled = { saveMutation . isPending }
className = "bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
>
{ saveMutation . isPending ? "Saving…" : "Save Doctor Slots" }
< / button >
< / div >
< / CardContent >
< / Card >
{ /* ── Section 3: Hygienist Time Slot Settings ── */ }
< Card >
< CardContent className = "py-6 space-y-4" >
< div >
< h2 className = "text-lg font-semibold text-gray-800" > Hygienist Time Slot Settings < / h2 >
< p className = "text-sm text-gray-500 mt-0.5" >
Define standard procedure types for hygienists with typical durations .
< / p >
< / div >
{ hygienistSlots . length > 0 && (
< div className = "border rounded overflow-hidden" >
< table className = "w-full text-sm" >
< thead className = "bg-gray-50 border-b" >
< tr >
< th className = "text-left px-3 py-2 font-medium text-gray-600" > Procedure Description < / th >
< th className = "text-left px-3 py-2 font-medium text-gray-600 w-36" > Duration < / th >
< th className = "w-10" / >
< / tr >
< / thead >
< tbody className = "divide-y" >
{ hygienistSlots . map ( ( slot ) = > (
< tr key = { slot . id } className = "hover:bg-gray-50" >
< td className = "px-3 py-2" >
< input
type = "text"
value = { slot . description }
onChange = { ( e ) = > updateHygSlot ( slot . id , "description" , e . target . value ) }
className = "w-full border rounded px-2 py-1 text-sm"
placeholder = "e.g. Exam / Recalls / Teeth Cleaning"
/ >
< / td >
< td className = "px-3 py-2" >
< select
value = { slot . durationMin }
onChange = { ( e ) = > updateHygSlot ( slot . id , "durationMin" , Number ( e . target . value ) ) }
className = "border rounded px-2 py-1 text-sm w-full"
>
{ DURATION_OPTIONS . map ( ( d ) = > (
< option key = { d } value = { d } > { d } min < / option >
) ) }
< / select >
< / td >
< td className = "px-2 py-2 text-center" >
< button onClick = { ( ) = > deleteHygSlot ( slot . id ) } className = "text-red-400 hover:text-red-600" >
< Trash2 size = { 15 } / >
< / button >
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) }
< div className = "flex gap-2 items-end" >
< div className = "flex flex-col gap-1 flex-1" >
< label className = "text-xs font-medium text-gray-600" > Procedure Description < / label >
< input
type = "text"
value = { newHygDesc }
onChange = { ( e ) = > setNewHygDesc ( e . target . value ) }
onKeyDown = { ( e ) = > e . key === "Enter" && addHygSlot ( ) }
className = "border rounded px-2 py-1.5 text-sm w-full"
placeholder = "e.g. Exam, Recalls / Teeth Cleaning"
/ >
< / div >
< div className = "flex flex-col gap-1" >
< label className = "text-xs font-medium text-gray-600" > Duration < / label >
< select
value = { newHygDuration }
onChange = { ( e ) = > setNewHygDuration ( Number ( e . target . value ) ) }
className = "border rounded px-2 py-1.5 text-sm"
>
{ DURATION_OPTIONS . map ( ( d ) = > (
< option key = { d } value = { d } > { d } min < / option >
) ) }
< / select >
< / div >
< button
onClick = { addHygSlot }
className = "flex items-center gap-1 bg-teal-600 text-white px-3 py-1.5 rounded hover:bg-teal-700 text-sm"
>
< Plus size = { 14 } / > Add
< / button >
< / div >
< div className = "flex justify-end pt-1" >
< button
onClick = { handleSaveHygienistSlots }
disabled = { saveMutation . isPending }
className = "bg-teal-600 text-white px-5 py-2 rounded hover:bg-teal-700 text-sm"
>
{ saveMutation . isPending ? "Saving…" : "Save Hygienist Slots" }
< / button >
< / div >
< / CardContent >
< / Card >
< / div >
) ;
}