feature: users with treatment edit permission should now have working hours defined. the appointment grid is now being drawn based on the doctor's working hours.

This commit is contained in:
2026-06-10 19:44:09 +03:30
parent 468572a50f
commit 48ecfd05e1
22 changed files with 1571 additions and 116 deletions

View File

@@ -46,7 +46,7 @@ export default function AppointmentsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [bookingOpen, setBookingOpen] = useState(false);
const [bookingHour, setBookingHour] = useState(9);
const [bookingStartMinute, setBookingStartMinute] = useState(9 * 60);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState('');
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
@@ -87,7 +87,7 @@ export default function AppointmentsPage() {
try {
const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([
appointmentsApi.columnProviders(),
appointmentsApi.columnProviders(scheduleDate),
appointmentsApi.list(range),
]);
if (gen !== scheduleLoadGen.current) {
@@ -161,7 +161,7 @@ export default function AppointmentsPage() {
}
}
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
if (isViewingPastDay) {
toast.showInfo('Past appointments are view-only.');
return;
@@ -170,7 +170,7 @@ export default function AppointmentsPage() {
toast.showInfo('Select a patient before booking.');
return;
}
setBookingHour(hour);
setBookingStartMinute(startMinute);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setEditingAppointmentId(null);
@@ -183,13 +183,20 @@ export default function AppointmentsPage() {
return;
}
const provider = providers.find((p) => p.userId === appointment.providerUserId);
setBookingHour(new Date(appointment.startAt).getHours());
const start = new Date(appointment.startAt);
setBookingStartMinute(start.getHours() * 60 + start.getMinutes());
setBookingProviderId(appointment.providerUserId);
setBookingProviderName(provider?.name ?? bookingProviderName);
setEditingAppointmentId(appointment.id);
setBookingOpen(true);
}
function handleAppointmentOutsideHours(appointment: AppointmentRecord) {
toast.showError(
'This appointment falls outside the providers current working hours and cannot be edited.',
);
}
async function handleSaveAppointment(payload: {
patientId: string;
providerUserId: string;
@@ -298,8 +305,9 @@ export default function AppointmentsPage() {
providers={providers}
appointments={appointments}
canBook={canManageAppointments && !isViewingPastDay}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
onSlotClick={(startMinute, uid, name) => handleSlotClick(startMinute, uid, name)}
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
onAppointmentOutsideHours={(apt) => handleAppointmentOutsideHours(apt)}
/>
</div>
</div>
@@ -310,7 +318,7 @@ export default function AppointmentsPage() {
patient={selectedPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialHour={bookingHour}
initialStartMinute={bookingStartMinute}
editingAppointment={activeEditingAppointment}
onClose={() => {
setBookingOpen(false);

View File

@@ -12,10 +12,18 @@ import {
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
featureStateHasTreatmentEdit,
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from '../../../components/staff/staff-permission-form';
import {
StaffWorkingHoursStep,
createDefaultWorkingHoursState,
workingHoursPayloadFromState,
workingHoursStateFromApi,
} from '@/components/staff/StaffWorkingHoursStep';
import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours';
import { Pencil, Trash2, Copy, Check, X, UserX, UserCheck } from 'lucide-react';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -144,9 +152,15 @@ export default function StaffPage() {
const toast = useToast();
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteStep, setInviteStep] = useState<1 | 2>(1);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteWorkingHoursDays, setInviteWorkingHoursDays] = useState<WorkingHoursEditorDay[]>(
() => createDefaultWorkingHoursState().days,
);
const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true);
const [inviteHoursValidationError, setInviteHoursValidationError] = useState<string | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState<string | null>(null);
@@ -160,8 +174,15 @@ export default function StaffPage() {
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editStep, setEditStep] = useState<1 | 2>(1);
const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editWorkingHoursDays, setEditWorkingHoursDays] = useState<WorkingHoursEditorDay[]>(
() => createDefaultWorkingHoursState().days,
);
const [editAutoRepeatWeekly, setEditAutoRepeatWeekly] = useState(true);
const [editHoursValidationError, setEditHoursValidationError] = useState<string | null>(null);
const [editLoadingWorkingHours, setEditLoadingWorkingHours] = useState(false);
const [editLoading, setEditLoading] = useState(false);
const [disableTarget, setDisableTarget] = useState<StaffMemberDto | null>(null);
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
@@ -169,6 +190,11 @@ export default function StaffPage() {
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const inviteHasTreatmentEdit = useMemo(
() => featureStateHasTreatmentEdit(invitePerms),
[invitePerms],
);
const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]);
const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false;
@@ -271,19 +297,60 @@ export default function StaffPage() {
}
}
async function submitInvite() {
function resetInviteForm() {
setInviteStep(1);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
const defaults = createDefaultWorkingHoursState();
setInviteWorkingHoursDays(defaults.days);
setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly);
setInviteHoursValidationError(null);
}
async function saveInviteWorkingHours(membershipId: string, includeHours: boolean) {
if (!includeHours || !inviteHasTreatmentEdit) {
return;
}
const validationError = validateEditorDays(inviteWorkingHoursDays);
if (validationError) {
throw new Error(validationError);
}
await staffApi.upsertWorkingHours(
membershipId,
workingHoursPayloadFromState({
days: inviteWorkingHoursDays,
autoRepeatWeekly: inviteAutoRepeatWeekly,
}),
);
}
async function submitInvite(includeWorkingHours: boolean) {
setInviteLoading(true);
toast.setError('');
setLastInviteInfo(null);
const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim();
try {
if (includeWorkingHours && inviteHasTreatmentEdit) {
const validationError = validateEditorDays(inviteWorkingHoursDays);
if (validationError) {
toast.showError(validationError);
return;
}
}
const permissionNames = permissionNamesFromFeatureState(invitePerms);
const res = await staffApi.invite({
email: displayEmail,
name: displayName,
permissionNames,
});
if (includeWorkingHours) {
await saveInviteWorkingHours(res.data.membershipId, true);
}
setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName,
@@ -304,9 +371,7 @@ export default function StaffPage() {
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setInviteOpen(false);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
resetInviteForm();
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.'));
@@ -315,17 +380,39 @@ export default function StaffPage() {
}
}
function openEdit(m: StaffMemberDto) {
async function openEdit(m: StaffMemberDto) {
if (m.isOwner) return;
setEditing(m);
setEditStep(1);
setEditName(m.name);
setEditPerms(
featureStateFromPermissionNames(m.permissions ?? []),
);
setEditPerms(featureStateFromPermissionNames(m.permissions ?? []));
setEditHoursValidationError(null);
const defaults = createDefaultWorkingHoursState();
setEditWorkingHoursDays(defaults.days);
setEditAutoRepeatWeekly(defaults.autoRepeatWeekly);
setEditLoadingWorkingHours(true);
try {
const res = await staffApi.getWorkingHours(m.id);
const state = workingHoursStateFromApi(res.data);
setEditWorkingHoursDays(state.days);
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to load working hours.'));
} finally {
setEditLoadingWorkingHours(false);
}
}
async function submitEdit() {
if (!editing) return;
if (editHasTreatmentEdit) {
const validationError = validateEditorDays(editWorkingHoursDays);
if (validationError) {
toast.showError(validationError);
return;
}
}
setEditLoading(true);
toast.setError('');
try {
@@ -333,8 +420,20 @@ export default function StaffPage() {
name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms),
});
if (editHasTreatmentEdit) {
await staffApi.upsertWorkingHours(
editing.id,
workingHoursPayloadFromState({
days: editWorkingHoursDays,
autoRepeatWeekly: editAutoRepeatWeekly,
}),
);
}
toast.showSuccess('Member updated.');
setEditing(null);
setEditStep(1);
await load();
} catch (e) {
toast.showError(formatApiErrorMessage(e, 'Failed to update member.'));
@@ -400,6 +499,7 @@ export default function StaffPage() {
size="sm"
onClick={() => {
if (!canEdit || atSeatLimit) return;
resetInviteForm();
setInviteOpen(true);
setLastInviteInfo(null);
}}
@@ -666,43 +766,110 @@ export default function StaffPage() {
aria-labelledby="invite-staff-title"
>
<div className="flex items-start justify-between gap-3">
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Invite team member
</h2>
<DialogCloseButton onClick={() => setInviteOpen(false)} />
</div>
<Input
label="Email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
<div>
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary pr-2">
Invite team member
</h2>
{inviteHasTreatmentEdit && (
<p className="text-xs text-text-muted mt-1">Step {inviteStep} of 2</p>
)}
</div>
<DialogCloseButton
onClick={() => {
setInviteOpen(false);
resetInviteForm();
}}
/>
</div>
{inviteStep === 1 ? (
<>
<Input
label="Email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div>
</>
) : (
<StaffWorkingHoursStep
days={inviteWorkingHoursDays}
autoRepeatWeekly={inviteAutoRepeatWeekly}
onDaysChange={setInviteWorkingHoursDays}
onAutoRepeatWeeklyChange={setInviteAutoRepeatWeekly}
onValidationChange={setInviteHoursValidationError}
disabled={inviteLoading}
/>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button
variant="outline"
type="button"
isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite()}
onClick={() => {
if (inviteStep === 2) {
setInviteStep(1);
return;
}
setInviteOpen(false);
resetInviteForm();
}}
>
Send invite
{inviteStep === 2 ? 'Back' : 'Cancel'}
</Button>
{inviteStep === 1 ? (
inviteHasTreatmentEdit ? (
<Button
type="button"
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => setInviteStep(2)}
>
Next
</Button>
) : (
<Button
type="button"
isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite(false)}
>
Send invite
</Button>
)
) : (
<>
<Button
type="button"
variant="outline"
isLoading={inviteLoading}
onClick={() => void submitInvite(false)}
>
Skip for now
</Button>
<Button
type="button"
isLoading={inviteLoading}
disabled={Boolean(inviteHoursValidationError)}
onClick={() => void submitInvite(true)}
>
Send invite
</Button>
</>
)}
</div>
</div>
</div>
@@ -830,26 +997,85 @@ export default function StaffPage() {
aria-modal="true"
>
<div className="flex items-start justify-between gap-3">
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
<DialogCloseButton onClick={() => setEditing(null)} />
</div>
<p className="text-xs text-text-muted">{editing.email}</p>
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
<div>
<h2 className="text-lg font-semibold text-text-primary pr-2">Edit member</h2>
{editHasTreatmentEdit && (
<p className="text-xs text-text-muted mt-1">Step {editStep} of 2</p>
)}
</div>
<DialogCloseButton
onClick={() => {
setEditing(null);
setEditStep(1);
}}
/>
</div>
<p className="text-xs text-text-muted">{editing.email}</p>
{editStep === 1 ? (
<>
<Input
label="Display name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div>
</>
) : editLoadingWorkingHours ? (
<p className="text-sm text-text-secondary">Loading working hours</p>
) : (
<StaffWorkingHoursStep
days={editWorkingHoursDays}
autoRepeatWeekly={editAutoRepeatWeekly}
onDaysChange={setEditWorkingHoursDays}
onAutoRepeatWeeklyChange={setEditAutoRepeatWeekly}
onValidationChange={setEditHoursValidationError}
disabled={editLoading}
/>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
<Button
variant="outline"
type="button"
onClick={() => {
if (editStep === 2) {
setEditStep(1);
return;
}
setEditing(null);
setEditStep(1);
}}
>
{editStep === 2 ? 'Back' : 'Cancel'}
</Button>
{editStep === 1 ? (
editHasTreatmentEdit ? (
<Button type="button" onClick={() => setEditStep(2)}>
Next
</Button>
) : (
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
</Button>
)
) : (
<Button
type="button"
isLoading={editLoading}
disabled={Boolean(editHoursValidationError)}
onClick={() => void submitEdit()}
>
Save
</Button>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,103 @@
'use client';
import { useEffect, useState } from 'react';
import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor';
import {
blocksFromEditorDays,
editorDaysFromBlocks,
emptyWorkingHoursEditorDays,
validateEditorDays,
type WorkingHoursEditorDay,
} from '@/components/staff/workingHours';
interface StaffWorkingHoursStepProps {
days: WorkingHoursEditorDay[];
autoRepeatWeekly: boolean;
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
onAutoRepeatWeeklyChange: (value: boolean) => void;
onValidationChange?: (error: string | null) => void;
disabled?: boolean;
}
export function StaffWorkingHoursStep({
days,
autoRepeatWeekly,
onDaysChange,
onAutoRepeatWeeklyChange,
onValidationChange,
disabled,
}: StaffWorkingHoursStepProps) {
useEffect(() => {
onValidationChange?.(validateEditorDays(days));
}, [days, onValidationChange]);
return (
<div className="space-y-3">
<div className="rounded-[var(--radius-md)] border border-primary/25 bg-primary/5 px-3 py-3">
<p className="text-sm text-text-primary font-medium">Working hours recommended</p>
<p className="text-sm text-text-secondary mt-1">
Staff with treatment edit access appear as provider columns in Appointments. Set their
weekly hours so the schedule grid shows the right bookable times.
</p>
</div>
<WorkingHoursEditor
days={days}
autoRepeatWeekly={autoRepeatWeekly}
onDaysChange={onDaysChange}
onAutoRepeatWeeklyChange={onAutoRepeatWeeklyChange}
disabled={disabled}
/>
</div>
);
}
export function createDefaultWorkingHoursState() {
return {
days: emptyWorkingHoursEditorDays(),
autoRepeatWeekly: true,
};
}
export function workingHoursPayloadFromState(state: {
days: WorkingHoursEditorDay[];
autoRepeatWeekly: boolean;
}) {
return {
autoRepeatWeekly: state.autoRepeatWeekly,
blocks: blocksFromEditorDays(state.days),
};
}
export function workingHoursStateFromApi(data: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
}) {
const hasBlocks = data.blocks.length > 0;
return {
days: hasBlocks ? editorDaysFromBlocks(data.blocks) : emptyWorkingHoursEditorDays(),
autoRepeatWeekly: data.autoRepeatWeekly,
};
}
export function useWorkingHoursForm(initial?: {
days: WorkingHoursEditorDay[];
autoRepeatWeekly: boolean;
}) {
const [days, setDays] = useState(initial?.days ?? emptyWorkingHoursEditorDays());
const [autoRepeatWeekly, setAutoRepeatWeekly] = useState(initial?.autoRepeatWeekly ?? true);
const [validationError, setValidationError] = useState<string | null>(null);
return {
days,
setDays,
autoRepeatWeekly,
setAutoRepeatWeekly,
validationError,
setValidationError,
reset(next?: { days: WorkingHoursEditorDay[]; autoRepeatWeekly: boolean }) {
setDays(next?.days ?? emptyWorkingHoursEditorDays());
setAutoRepeatWeekly(next?.autoRepeatWeekly ?? true);
setValidationError(null);
},
};
}

View File

@@ -0,0 +1,171 @@
'use client';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import {
MINUTES_PER_DAY,
WEEKDAY_LABELS,
minutesToTimeInput,
timeInputToMinutes,
type WorkingHoursEditorDay,
} from '@/components/staff/workingHours';
interface WorkingHoursEditorProps {
days: WorkingHoursEditorDay[];
autoRepeatWeekly: boolean;
onDaysChange: (days: WorkingHoursEditorDay[]) => void;
onAutoRepeatWeeklyChange: (value: boolean) => void;
disabled?: boolean;
}
const timeInputClass =
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
export function WorkingHoursEditor({
days,
autoRepeatWeekly,
onDaysChange,
onAutoRepeatWeeklyChange,
disabled = false,
}: WorkingHoursEditorProps) {
function updateDay(dayOfWeek: number, patch: Partial<WorkingHoursEditorDay>) {
onDaysChange(
days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
);
}
function updateShift(
dayOfWeek: number,
shiftIndex: number,
field: 'startMinute' | 'endMinute',
value: string,
) {
const minutes = timeInputToMinutes(value);
if (minutes == null) return;
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
if (!day) return;
const shifts = day.shifts.map((shift, index) =>
index === shiftIndex ? { ...shift, [field]: minutes } : shift,
);
updateDay(dayOfWeek, { shifts });
}
function addShift(dayOfWeek: number) {
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
if (!day) return;
const last = day.shifts[day.shifts.length - 1];
const startMinute = last ? Math.min(last.endMinute + 60, MINUTES_PER_DAY - 60) : 9 * 60;
updateDay(dayOfWeek, {
shifts: [...day.shifts, { startMinute, endMinute: Math.min(startMinute + 120, MINUTES_PER_DAY) }],
});
}
function removeShift(dayOfWeek: number, shiftIndex: number) {
const day = days.find((d) => d.dayOfWeek === dayOfWeek);
if (!day || day.shifts.length <= 1) return;
updateDay(dayOfWeek, {
shifts: day.shifts.filter((_, index) => index !== shiftIndex),
});
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Set weekly working hours for this provider. The appointments grid uses these hours to show
bookable time slots.
</p>
<div className="space-y-3">
{days.map((day) => (
<div
key={day.dayOfWeek}
className="rounded-[var(--radius-md)] border border-border/60 bg-background-card/40 px-3 py-3 space-y-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-text-primary w-10">
{WEEKDAY_LABELS[day.dayOfWeek]}
</span>
<Checkbox
checked={day.isWorking}
disabled={disabled}
label="Working day"
onChange={(checked) => {
updateDay(day.dayOfWeek, {
isWorking: checked,
shifts: checked
? day.shifts.length > 0
? day.shifts
: [{ startMinute: 9 * 60, endMinute: 17 * 60 }]
: day.shifts,
});
}}
/>
</div>
{day.isWorking && (
<div className="space-y-2 pl-0 sm:pl-10">
{day.shifts.map((shift, shiftIndex) => (
<div key={shiftIndex} className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">Start</label>
<input
type="time"
step={300}
disabled={disabled}
className={timeInputClass}
value={minutesToTimeInput(shift.startMinute)}
onChange={(e) =>
updateShift(day.dayOfWeek, shiftIndex, 'startMinute', e.target.value)
}
/>
</div>
<div className="flex-1">
<label className="block text-xs text-text-muted mb-1">End</label>
<input
type="time"
step={300}
disabled={disabled}
className={timeInputClass}
value={minutesToTimeInput(shift.endMinute)}
onChange={(e) =>
updateShift(day.dayOfWeek, shiftIndex, 'endMinute', e.target.value)
}
/>
</div>
<button
type="button"
disabled={disabled || day.shifts.length <= 1}
className="p-2 rounded-md text-text-muted hover:text-red-500 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Remove shift"
onClick={() => removeShift(day.dayOfWeek, shiftIndex)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => addShift(day.dayOfWeek)}
>
<Plus className="w-3.5 h-3.5 mr-1" />
Add shift
</Button>
</div>
)}
</div>
))}
</div>
<Checkbox
checked={autoRepeatWeekly}
disabled={disabled}
label="Repeat these hours at the start of each week (copy forward on Monday)"
onChange={onAutoRepeatWeeklyChange}
/>
</div>
);
}

View File

@@ -57,6 +57,10 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string
return out;
}
export function featureStateHasTreatmentEdit(state: FeaturePermState): boolean {
return Boolean(state.TAB_TREATMENT_EDIT?.edit);
}
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(
permissionNames: string[] | null | undefined,

View File

@@ -0,0 +1,213 @@
export const MINUTES_PER_DAY = 24 * 60;
export const SCHEDULE_SLOT_MINUTES = 15;
export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const;
export type WorkingHoursBlock = {
dayOfWeek: number;
startMinute: number;
endMinute: number;
sortOrder?: number;
};
export type WorkingHoursDayBlock = {
startMinute: number;
endMinute: number;
};
export type WorkingHoursEditorDay = {
dayOfWeek: number;
isWorking: boolean;
shifts: { startMinute: number; endMinute: number }[];
};
export function localDayOfWeekMondayZero(dayOfWeekJs: number): number {
return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1;
}
export function minutesToTimeInput(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function timeInputToMinutes(value: string): number | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return h * 60 + m;
}
export function formatMinuteLabel(minute: number): string {
const d = new Date(2000, 0, 1, Math.floor(minute / 60), minute % 60, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
}
export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] {
return WEEKDAY_LABELS.map((_, dayOfWeek) => ({
dayOfWeek,
isWorking: dayOfWeek <= 4,
shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
}));
}
export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursEditorDay[] {
const byDay = new Map<number, WorkingHoursDayBlock[]>();
for (const block of blocks) {
const list = byDay.get(block.dayOfWeek) ?? [];
list.push({ startMinute: block.startMinute, endMinute: block.endMinute });
byDay.set(block.dayOfWeek, list);
}
return WEEKDAY_LABELS.map((_, dayOfWeek) => {
const shifts = (byDay.get(dayOfWeek) ?? []).sort(
(a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute,
);
return {
dayOfWeek,
isWorking: shifts.length > 0,
shifts: shifts.length > 0 ? shifts : [{ startMinute: 9 * 60, endMinute: 17 * 60 }],
};
});
}
export function blocksFromEditorDays(days: WorkingHoursEditorDay[]): WorkingHoursBlock[] {
const blocks: WorkingHoursBlock[] = [];
for (const day of days) {
if (!day.isWorking) continue;
day.shifts.forEach((shift, index) => {
blocks.push({
dayOfWeek: day.dayOfWeek,
startMinute: shift.startMinute,
endMinute: shift.endMinute,
sortOrder: index,
});
});
}
return blocks;
}
export function validateEditorDays(days: WorkingHoursEditorDay[]): string | null {
for (const day of days) {
if (!day.isWorking) continue;
if (day.shifts.length === 0) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} needs at least one shift or should be marked off.`;
}
const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute);
for (const shift of sorted) {
if (shift.endMinute <= shift.startMinute) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} shift end time must be after start time.`;
}
}
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].startMinute < sorted[i - 1].endMinute) {
return `${WEEKDAY_LABELS[day.dayOfWeek]} shifts cannot overlap.`;
}
}
}
return null;
}
export function blocksForDay(blocks: WorkingHoursBlock[], dayOfWeek: number): WorkingHoursDayBlock[] {
return blocks
.filter((b) => b.dayOfWeek === dayOfWeek)
.sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute)
.map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute }));
}
export function isMinuteWithinWorkingBlocks(
minute: number,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute);
}
export function isSlotWithinWorkingBlocks(
slotStartMinute: number,
slotMinutes: number,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
const slotEnd = slotStartMinute + slotMinutes;
for (let m = slotStartMinute; m < slotEnd; m += 1) {
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
return false;
}
}
return true;
}
export function appointmentWithinWorkingHours(
startAt: Date,
endAt: Date,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
const startMinute = startAt.getHours() * 60 + startAt.getMinutes();
const endMinute = endAt.getHours() * 60 + endAt.getMinutes();
if (endMinute <= startMinute) return false;
for (let m = startMinute; m < endMinute; m += 1) {
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
return false;
}
}
return true;
}
export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): {
startMinute: number;
endMinute: number;
} | null {
let startMinute: number | null = null;
let endMinute: number | null = null;
for (const dayBlocks of dayBlocksList) {
for (const block of dayBlocks) {
startMinute =
startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute);
endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute);
}
}
if (startMinute == null || endMinute == null) {
return null;
}
return { startMinute, endMinute };
}
export function snapRangeToSlots(
startMinute: number,
endMinute: number,
slotMinutes: number,
): { startMinute: number; endMinute: number; slotCount: number } {
const start = Math.floor(startMinute / slotMinutes) * slotMinutes;
const end = Math.ceil(endMinute / slotMinutes) * slotMinutes;
return {
startMinute: start,
endMinute: end,
slotCount: Math.max(1, (end - start) / slotMinutes),
};
}
export function generateSlotStarts(
startMinute: number,
endMinute: number,
slotMinutes: number,
): number[] {
const slots: number[] = [];
for (let m = startMinute; m < endMinute; m += slotMinutes) {
slots.push(m);
}
return slots;
}
export function generateHourLabelsInRange(startMinute: number, endMinute: number): number[] {
const firstHour = Math.floor(startMinute / 60);
const lastHour = Math.ceil(endMinute / 60);
const hours: number[] = [];
for (let h = firstHour; h < lastHour; h += 1) {
hours.push(h);
}
return hours;
}

View File

@@ -20,7 +20,7 @@ interface AppointmentBookingModalProps {
patient: Patient | undefined;
providerUserId: string | null;
providerName: string;
initialHour: number;
initialStartMinute: number;
onClose: () => void;
onSubmit: (payload: {
patientId: string;
@@ -42,7 +42,7 @@ export function AppointmentBookingModal({
patient,
providerUserId,
providerName,
initialHour,
initialStartMinute,
onClose,
onSubmit,
editingAppointment = null,
@@ -81,17 +81,18 @@ export function AppointmentBookingModal({
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour,
0,
Math.floor(initialStartMinute / 60),
initialStartMinute % 60,
0,
0,
);
const endMinute = Math.min(initialStartMinute + 60, 24 * 60 - 1);
const end = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour < 23 ? initialHour + 1 : 23,
initialHour < 23 ? 0 : 59,
Math.floor(endMinute / 60),
endMinute % 60,
0,
0,
);
@@ -100,7 +101,7 @@ export function AppointmentBookingModal({
setPurpose('consultation');
}
setError('');
}, [open, scheduleDate, initialHour, editingAppointment]);
}, [open, scheduleDate, initialStartMinute, editingAppointment]);
if (!open || !providerUserId) {
return null;

View File

@@ -2,7 +2,16 @@
import { useMemo, useState } from 'react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/components/appointments/appointmentTime';
import {
SCHEDULE_SLOT_MINUTES,
appointmentWithinWorkingHours,
formatMinuteLabel,
generateHourLabelsInRange,
generateSlotStarts,
isSlotWithinWorkingBlocks,
snapRangeToSlots,
unionDayBlockRange,
} from '@/components/staff/workingHours';
import {
computeAppointmentLaneLayouts,
findOverlapCluster,
@@ -10,23 +19,30 @@ import {
} from '@/components/appointments/appointmentOverlapLayout';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { AppointmentOverlapPopover } from '@/components/ui/appointments/AppointmentOverlapPopover';
import { startOfLocalDay } from '@/components/appointments/appointmentTime';
const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const SLOT_PX = (HOUR_PX * SCHEDULE_SLOT_MINUTES) / 60;
function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null {
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0);
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0);
function layoutBlockInRange(
apt: AppointmentRecord,
day: Date,
rangeStartMinute: number,
rangeEndMinute: number,
): { top: string; height: string } | null {
const dayStart = startOfLocalDay(day);
const rangeStartMs = dayStart.getTime() + rangeStartMinute * 60_000;
const rangeEndMs = dayStart.getTime() + rangeEndMinute * 60_000;
const start = new Date(apt.startAt);
const end = new Date(apt.endAt);
const ms = dayEnd.getTime() - dayStart.getTime();
const clipStart = Math.max(start.getTime(), dayStart.getTime());
const clipEnd = Math.min(end.getTime(), dayEnd.getTime());
const clipStart = Math.max(start.getTime(), rangeStartMs);
const clipEnd = Math.min(end.getTime(), rangeEndMs);
if (clipEnd <= clipStart) {
return null;
}
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
const height = ((clipEnd - clipStart) / ms) * 100;
const rangeMs = rangeEndMs - rangeStartMs;
const top = ((clipStart - rangeStartMs) / rangeMs) * 100;
const height = ((clipEnd - clipStart) / rangeMs) * 100;
return { top: `${top}%`, height: `${height}%` };
}
@@ -36,12 +52,12 @@ function appointmentDurationMinutes(apt: AppointmentRecord): number {
return Math.max(0, Math.round((end - start) / 60_000));
}
function appointmentBannerHeightPx(durationMin: number): number {
return (durationMin / (24 * 60)) * HOURS.length * HOUR_PX;
function appointmentBannerHeightPx(durationMin: number, rangeMinutes: number, gridHeight: number): number {
return (durationMin / rangeMinutes) * gridHeight;
}
function shortBannerNameClass(durationMin: number): string {
const heightPx = appointmentBannerHeightPx(durationMin);
function shortBannerNameClass(durationMin: number, rangeMinutes: number, gridHeight: number): string {
const heightPx = appointmentBannerHeightPx(durationMin, rangeMinutes, gridHeight);
if (heightPx < 18) {
return 'text-[8px] leading-none';
}
@@ -61,8 +77,9 @@ interface AppointmentScheduleGridProps {
providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[];
canBook: boolean;
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
onSlotClick: (startMinute: number, providerUserId: string, providerName: string) => void;
onAppointmentClick?: (appointment: AppointmentRecord) => void;
onAppointmentOutsideHours?: (appointment: AppointmentRecord) => void;
}
export function AppointmentScheduleGrid({
@@ -72,10 +89,39 @@ export function AppointmentScheduleGrid({
canBook,
onSlotClick,
onAppointmentClick,
onAppointmentOutsideHours,
}: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX;
const [overlapPopover, setOverlapPopover] = useState<OverlapPopoverState | null>(null);
const visibleRange = useMemo(() => {
const activeDayBlocks = providers
.filter((p) => p.hasWorkingHours && p.dayBlocks.length > 0)
.map((p) => p.dayBlocks);
return unionDayBlockRange(activeDayBlocks);
}, [providers]);
const snappedRange = useMemo(() => {
if (!visibleRange) return null;
return snapRangeToSlots(visibleRange.startMinute, visibleRange.endMinute, SCHEDULE_SLOT_MINUTES);
}, [visibleRange]);
const slotStarts = useMemo(() => {
if (!snappedRange) return [];
return generateSlotStarts(
snappedRange.startMinute,
snappedRange.endMinute,
SCHEDULE_SLOT_MINUTES,
);
}, [snappedRange]);
const hourLabels = useMemo(() => {
if (!snappedRange) return [];
return generateHourLabelsInRange(snappedRange.startMinute, snappedRange.endMinute);
}, [snappedRange]);
const gridHeight = slotStarts.length * SLOT_PX;
const rangeMinutes = snappedRange ? snappedRange.endMinute - snappedRange.startMinute : 0;
const laneLayoutsByProvider = useMemo(() => {
const map = new Map<string, ReturnType<typeof computeAppointmentLaneLayouts>>();
for (const provider of providers) {
@@ -87,9 +133,19 @@ export function AppointmentScheduleGrid({
function handleAppointmentBannerClick(
apt: AppointmentRecord,
provider: AppointmentColumnProvider,
providerAppointments: AppointmentRecord[],
anchor: HTMLElement,
) {
if (
provider.hasWorkingHours &&
provider.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(new Date(apt.startAt), new Date(apt.endAt), provider.dayBlocks)
) {
onAppointmentOutsideHours?.(apt);
return;
}
const cluster = findOverlapCluster(apt.id, providerAppointments);
if (cluster.length > 1) {
setOverlapPopover({
@@ -109,6 +165,15 @@ export function AppointmentScheduleGrid({
);
}
if (!snappedRange || slotStarts.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No working hours are configured for this day. Set provider working hours in Staff
management.
</div>
);
}
return (
<>
<div className="surface-card overflow-x-auto">
@@ -121,21 +186,38 @@ export function AppointmentScheduleGrid({
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
>
{p.name}
{!p.hasWorkingHours && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
No hours set
</span>
)}
{p.hasWorkingHours && p.dayBlocks.length === 0 && (
<span className="block text-[10px] font-normal text-text-muted mt-0.5">
Off today
</span>
)}
</div>
))}
</div>
<div className="flex">
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
{HOURS.map((h) => (
<div
key={h}
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ height: HOUR_PX }}
>
{formatHourLabel(h)}
</div>
))}
<div
className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40 relative"
style={{ height: gridHeight }}
>
{hourLabels.map((hour) => {
const top = ((hour * 60 - snappedRange.startMinute) / rangeMinutes) * gridHeight;
const height = (60 / rangeMinutes) * gridHeight;
return (
<div
key={hour}
className="absolute left-0 right-0 text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ top, height }}
>
{formatMinuteLabel(hour * 60)}
</div>
);
})}
</div>
<div className="flex-1 flex min-w-0">
@@ -144,6 +226,7 @@ export function AppointmentScheduleGrid({
(a) => a.providerUserId === p.userId,
);
const laneLayouts = laneLayoutsByProvider.get(p.userId) ?? new Map();
const columnFullyDisabled = !p.hasWorkingHours || p.dayBlocks.length === 0;
return (
<div
@@ -151,31 +234,50 @@ export function AppointmentScheduleGrid({
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
>
{HOURS.map((h) => {
const slotDisabled = !canBook;
{slotStarts.map((slotStartMinute, index) => {
const slotActive =
!columnFullyDisabled &&
isSlotWithinWorkingBlocks(
slotStartMinute,
SCHEDULE_SLOT_MINUTES,
p.dayBlocks,
);
const slotDisabled = !canBook || columnFullyDisabled || !slotActive;
return (
<button
key={h}
key={`${p.userId}-${slotStartMinute}`}
type="button"
disabled={slotDisabled}
title={
slotDisabled
? 'You cannot create appointments'
: `Book ${formatHourLabel(h)}`
columnFullyDisabled
? p.hasWorkingHours
? 'Provider is off today'
: 'Working hours not configured'
: !slotActive
? 'Outside working hours'
: slotDisabled
? 'You cannot create appointments'
: `Book ${formatMinuteLabel(slotStartMinute)}`
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed opacity-50'
? 'cursor-not-allowed bg-background-secondary/35 opacity-60'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: h * HOUR_PX, height: HOUR_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)}
style={{ top: index * SLOT_PX, height: SLOT_PX }}
onClick={() => onSlotClick(slotStartMinute, p.userId, p.name)}
/>
);
})}
{providerAppointments.map((apt) => {
const pos = layoutBlock(apt, day);
const pos = layoutBlockInRange(
apt,
day,
snappedRange.startMinute,
snappedRange.endMinute,
);
if (!pos) {
return null;
}
@@ -184,9 +286,18 @@ export function AppointmentScheduleGrid({
const durationMin = appointmentDurationMinutes(apt);
const clusterSize = findOverlapCluster(apt.id, providerAppointments).length;
const isUnderOneHour = durationMin < 60;
const outsideHours =
p.hasWorkingHours &&
p.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
p.dayBlocks,
);
const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`;
const bannerTitle = [
patientName,
outsideHours ? 'Outside working hours — editing blocked' : null,
clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null,
!isUnderOneHour && apt.patient.phone ? apt.patient.phone : null,
]
@@ -198,9 +309,16 @@ export function AppointmentScheduleGrid({
type="button"
key={apt.id}
onClick={(e) =>
handleAppointmentBannerClick(apt, providerAppointments, e.currentTarget)
handleAppointmentBannerClick(
apt,
p,
providerAppointments,
e.currentTarget,
)
}
className={`absolute min-h-0 overflow-hidden rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 ${
outsideHours ? 'opacity-70 ring-1 ring-amber-500/60' : ''
} ${
isUnderOneHour
? 'items-center justify-center px-0.5 py-0'
: 'flex-col justify-start gap-0.5 px-1 py-0.5'
@@ -214,7 +332,7 @@ export function AppointmentScheduleGrid({
title={bannerTitle}
>
<span
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin)}`}
className={`block w-full truncate pointer-events-none font-medium ${shortBannerNameClass(durationMin, rangeMinutes, gridHeight)}`}
>
{patientName}
</span>
@@ -245,7 +363,24 @@ export function AppointmentScheduleGrid({
<AppointmentOverlapPopover
appointments={overlapPopover.appointments}
anchorRect={overlapPopover.anchorRect}
onSelect={(apt) => onAppointmentClick?.(apt)}
onSelect={(apt) => {
const provider = providers.find((p) => p.userId === apt.providerUserId);
if (
provider &&
provider.hasWorkingHours &&
provider.dayBlocks.length > 0 &&
!appointmentWithinWorkingHours(
new Date(apt.startAt),
new Date(apt.endAt),
provider.dayBlocks,
)
) {
onAppointmentOutsideHours?.(apt);
setOverlapPopover(null);
return;
}
onAppointmentClick?.(apt);
}}
onClose={() => setOverlapPopover(null)}
/>
)}

View File

@@ -1,5 +1,6 @@
import { apiClient } from './client';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { toDateInputValue } from '@/components/appointments/appointmentTime';
export interface CreateAppointmentBody {
patientId: string;
@@ -12,8 +13,11 @@ export interface CreateAppointmentBody {
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
export const appointmentsApi = {
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const response = await apiClient.get('/appointments/column-providers');
columnProviders: async (
scheduleDate?: Date,
): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const params = scheduleDate ? { date: toDateInputValue(scheduleDate) } : undefined;
const response = await apiClient.get('/appointments/column-providers', { params });
return response.data;
},

View File

@@ -120,4 +120,29 @@ export const staffApi = {
const response = await apiClient.delete(`/staff/members/${membershipId}`);
return response.data;
},
getWorkingHours: async (
membershipId: string,
): Promise<{
success: boolean;
data: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
hasWorkingHours: boolean;
};
}> => {
const response = await apiClient.get(`/staff/members/${membershipId}/working-hours`);
return response.data;
},
upsertWorkingHours: async (
membershipId: string,
body: {
autoRepeatWeekly: boolean;
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
},
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, body);
return response.data;
},
};

View File

@@ -13,6 +13,8 @@ export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export interface AppointmentColumnProvider {
userId: string;
name: string;
hasWorkingHours: boolean;
dayBlocks: { startMinute: number; endMinute: number }[];
}
export interface AppointmentRecord {