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:
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal file
103
frontend/src/components/staff/StaffWorkingHoursStep.tsx
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal file
171
frontend/src/components/staff/WorkingHoursEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
213
frontend/src/components/staff/workingHours.ts
Normal file
213
frontend/src/components/staff/workingHours.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user