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

@@ -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;
}