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>