Compare commits

...

9 Commits

Author SHA1 Message Date
b6b408e41e feature: FDI tooth chart UI finalized. 2026-05-08 12:42:01 +03:30
1a11323b74 feature: FDI tooth chart UI updated. 2026-05-08 03:58:53 +03:30
c2d490a5e7 feature: Preview added for treatments. 2026-05-07 14:20:50 +03:30
58cb35e26d feature: a minor refactor in folder structure done. 2026-05-07 03:46:18 +03:30
a70cbc7860 feature: a mocked implementation of v1 UI done. 2026-05-07 03:40:29 +03:30
2161926fc9 Merge pull request 'feature/appointment' (#15) from feature/appointment into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/15
2026-05-07 01:17:28 +03:30
f1c97021f8 feature: delete appointment button's UI improved 2026-05-07 01:14:55 +03:30
8123a94a3d feature: a minimal implementation of the appointment feature done. 2026-05-06 23:05:37 +03:30
bc06be3ca6 Merge pull request 'feature/clinic-lab-invitation' (#14) from feature/clinic-lab-invitation into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/14
2026-05-06 19:28:12 +03:30
37 changed files with 3261 additions and 16 deletions

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "appointments" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"patientId" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"purpose" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "appointments_organizationId_providerUserId_startAt_idx" ON "appointments"("organizationId", "providerUserId", "startAt");
-- CreateIndex
CREATE INDEX "appointments_organizationId_startAt_idx" ON "appointments"("organizationId", "startAt");
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -60,6 +60,7 @@ model Organization {
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -82,6 +83,7 @@ model Patient {
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
appointments Appointment[]
@@index([organizationId, createdAt])
@@index([organizationId, lastName, firstName])
@@ -106,6 +108,26 @@ model PatientTreatmentHistory {
@@map("patient_treatment_histories")
}
model Appointment {
id String @id @default(uuid())
organizationId String
patientId String
providerUserId String
startAt DateTime
endAt DateTime
purpose String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, providerUserId, startAt])
@@index([organizationId, startAt])
@@map("appointments")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"

View File

@@ -9,6 +9,7 @@ import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module';
import { StaffModule } from './modules/staff/staff.module';
import { OrganizationModule } from './modules/organization/organization.module';
import { AppointmentsModule } from './modules/appointments/appointments.module';
@Module({
imports: [
@@ -19,6 +20,7 @@ import { OrganizationModule } from './modules/organization/organization.module';
PrismaModule, // ✅ ADD THIS
AuthModule,
PatientsModule,
AppointmentsModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppointmentsService } from './appointments.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
@ApiTags('appointments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('appointments')
export class AppointmentsController {
constructor(private readonly appointmentsService: AppointmentsService) {}
@Get('column-providers')
@ApiOperation({
summary:
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.',
})
columnProviders(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.listColumnProviders(organizationId, req.user.id);
}
@Get()
@ApiOperation({ summary: 'List appointments intersecting a time range (requires TAB_APPOINTMENTS_READ or owner)' })
list(@Query() query: ListAppointmentsDto, @Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.list(query, organizationId, req.user.id);
}
@Post()
@ApiOperation({ summary: 'Create appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
create(
@Body() dto: CreateAppointmentDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.create(dto, organizationId, req.user.id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
remove(
@Param('id') id: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.remove(id, organizationId, req.user.id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { AppointmentsController } from './appointments.controller';
import { AppointmentsService } from './appointments.service';
@Module({
controllers: [AppointmentsController],
providers: [AppointmentsService, PrismaService],
})
export class AppointmentsModule {}

View File

@@ -0,0 +1,235 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
const MS_PER_DAY = 86_400_000;
@Injectable()
export class AppointmentsService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async listColumnProviders(organizationId: string, actorUserId: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isOwner: false,
isActive: true,
permissions: {
some: {
permission: {
name: 'TAB_TREATMENT_EDIT',
},
},
},
},
include: {
user: { select: { id: true, name: true } },
},
orderBy: [{ createdAt: 'asc' }],
});
const data = members.map((m) => ({ userId: m.user.id, name: m.user.name }));
return { success: true, data };
}
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const from = new Date(query.from);
const to = new Date(query.to);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid date range');
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
}
const items = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
include: {
patient: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
},
orderBy: [{ startAt: 'asc' }],
});
return { success: true, data: items };
}
async create(
dto: CreateAppointmentDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditAppointments(actorUserId, organizationId);
const startAt = new Date(dto.startAt);
const endAt = new Date(dto.endAt);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new BadRequestException('Invalid start or end time');
}
if (endAt <= startAt) {
throw new BadRequestException('End time must be after start time');
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new BadRequestException('Appointment cannot span more than 24 hours');
}
const now = Date.now();
if (startAt.getTime() < now) {
throw new BadRequestException('Cannot schedule appointments in the past');
}
await this.ensurePatientInOrg(dto.patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
const overlap = await this.prisma.appointment.findFirst({
where: {
organizationId,
providerUserId: dto.providerUserId,
startAt: { lt: endAt },
endAt: { gt: startAt },
},
select: { id: true },
});
if (overlap) {
throw new BadRequestException('This time slot overlaps an existing appointment for that provider');
}
const appointment = await this.prisma.appointment.create({
data: {
organizationId,
patientId: dto.patientId,
providerUserId: dto.providerUserId,
startAt,
endAt,
purpose: dto.purpose,
},
include: {
patient: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
},
});
return { success: true, data: appointment };
}
async remove(id: string, organizationId: string, actorUserId: string) {
await this.assertCanEditAppointments(actorUserId, organizationId);
const existing = await this.prisma.appointment.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!existing) {
throw new NotFoundException('Appointment not found');
}
await this.prisma.appointment.delete({
where: { id },
});
return { success: true };
}
private async assertCanViewAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_READ')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to appointments');
}
private async assertCanEditAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot create or modify appointments');
}
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
const m = await this.getMembership(providerUserId, organizationId);
if (!m) {
throw new BadRequestException('Provider is not a member of this organization');
}
if (m.isOwner) {
throw new BadRequestException(
'Appointments must be assigned to staff with treatment access, not the organization owner',
);
}
if (!m.isActive) {
throw new BadRequestException('Provider is not an active staff member');
}
const names = m.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TREATMENT_EDIT')) {
throw new BadRequestException('Provider does not have treatment edit access');
}
}
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: { id: patientId, organizationId },
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
}
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: { permissions: { include: { permission: true } } },
});
}
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsIn, IsUUID } from 'class-validator';
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export class CreateAppointmentDto {
@ApiProperty()
@IsUUID()
patientId: string;
@ApiProperty({ description: 'Staff user id (column provider)' })
@IsUUID()
providerUserId: string;
@ApiProperty({ example: '2026-05-06T09:15:00.000Z' })
@IsDateString()
startAt: string;
@ApiProperty({ example: '2026-05-06T09:45:00.000Z' })
@IsDateString()
endAt: string;
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
@IsIn([...APPOINTMENT_PURPOSES])
purpose: AppointmentPurpose;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString } from 'class-validator';
export class ListAppointmentsDto {
@ApiProperty({ description: 'Range start (ISO 8601), e.g. local midnight as instant' })
@IsDateString()
from: string;
@ApiProperty({ description: 'Range end (ISO 8601), e.g. next local midnight' })
@IsDateString()
to: string;
}

View File

@@ -1,10 +1,349 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError';
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
phone: '',
email: '',
};
export default function AppointmentsPage() {
const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const [scheduleError, setScheduleError] = useState('');
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [loadingPatients, setLoadingPatients] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [bookingOpen, setBookingOpen] = useState(false);
const [bookingHour, setBookingHour] = useState(9);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState('');
const [savingAppointment, setSavingAppointment] = useState(false);
const [toastError, setToastError] = useState('');
const [toastSuccess, setToastSuccess] = useState('');
const [toastInfo, setToastInfo] = useState('');
const canManageAppointments = canEditAppointments(currentOrganization);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const scheduleLoadGen = useRef(0);
const sortedPatients = useMemo(
() =>
[...patients].sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
[patients],
);
const loadSchedule = useCallback(async () => {
if (!currentOrganization?.id) {
return;
}
const gen = ++scheduleLoadGen.current;
setLoadingSchedule(true);
setScheduleError('');
try {
const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([
appointmentsApi.columnProviders(),
appointmentsApi.list(range),
]);
if (gen !== scheduleLoadGen.current) {
return;
}
setProviders(pRes.data);
setAppointments(aRes.data);
} catch (err: unknown) {
if (gen !== scheduleLoadGen.current) {
return;
}
setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.'));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
}
}
}, [currentOrganization?.id, scheduleDate]);
useEffect(() => {
void loadSchedule();
}, [loadSchedule]);
useEffect(() => {
const t = setTimeout(() => {
void loadPatientsSearch(search);
}, 300);
return () => clearTimeout(t);
}, [search]);
async function loadPatientsSearch(q: string) {
if (!currentOrganization) {
return;
}
setLoadingPatients(true);
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
setPatients(items);
if (selectedPatient) {
const stillThere = items.find((p) => p.id === selectedPatient.id);
if (stillThere) {
setSelectedPatient(stillThere);
}
}
} catch {
setPatients([]);
} finally {
setLoadingPatients(false);
}
}
async function handleCreatePatient() {
setSavingPatient(true);
setToastError('');
setToastSuccess('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
await loadPatientsSearch(search);
setSelectedPatient(response.data);
setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Failed to save patient.';
setToastError(message);
} finally {
setSavingPatient(false);
}
}
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
if (!selectedPatient) {
setToastSuccess('');
setToastError('');
setToastInfo('Select a patient before booking.');
return;
}
setBookingHour(hour);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setBookingOpen(true);
}
async function handleSaveAppointment(payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) {
setSavingAppointment(true);
setToastError('');
setToastSuccess('');
setToastInfo('');
try {
await appointmentsApi.create(payload);
setBookingOpen(false);
setToastSuccess('Appointment saved.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Could not save appointment.';
setToastError(message);
} finally {
setSavingAppointment(false);
}
}
async function handleDeleteAppointment(id: string) {
if (!window.confirm('Remove this appointment?')) {
return;
}
setToastError('');
setToastSuccess('');
setToastInfo('');
try {
await appointmentsApi.remove(id);
setToastSuccess('Appointment removed.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Could not delete appointment.';
setToastError(message);
}
}
useEffect(() => {
if (!toastSuccess) {
return;
}
const id = setTimeout(() => setToastSuccess(''), 3200);
return () => clearTimeout(id);
}, [toastSuccess]);
useEffect(() => {
if (!toastError) {
return;
}
const id = setTimeout(() => setToastError(''), 4000);
return () => clearTimeout(id);
}, [toastError]);
useEffect(() => {
if (!toastInfo) {
return;
}
const id = setTimeout(() => setToastInfo(''), 4000);
return () => clearTimeout(id);
}, [toastInfo]);
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
<p className="text-sm text-text-secondary">
Appointments module is coming soon.
</p>
<div className="relative space-y-6 pb-24">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
<p className="text-sm text-text-secondary">
Search a patient, pick a date, then click a time slot under a provider to book.
</p>
</div>
<AppointmentsPatientSearch
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
canAddPatient={canEditPatients}
onAddPatient={() => {
if (!canEditPatients) {
return;
}
setIsCreateOpen(true);
}}
/>
<PatientSummaryCard patient={selectedPatient} />
</div>
<div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
minDate={todayStart}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
<p className="text-sm text-text-muted pb-2">Loading schedule</p>
)}
</div>
{scheduleError && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{scheduleError}
</div>
)}
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}
appointments={appointments}
canBook={canManageAppointments}
canDelete={canManageAppointments}
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
/>
</div>
</div>
<AppointmentBookingModal
open={bookingOpen}
scheduleDate={scheduleDate}
patient={selectedPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialHour={bookingHour}
onClose={() => setBookingOpen(false)}
onSubmit={handleSaveAppointment}
loading={savingAppointment}
/>
{isCreateOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55">
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => setIsCreateOpen(false)}
loading={savingPatient}
/>
</div>
)}
{(toastError || toastSuccess || toastInfo) && (
<div className="fixed bottom-4 left-4 right-4 z-[70] flex justify-center pointer-events-none">
<div className="pointer-events-auto w-full max-w-lg space-y-2">
{toastError && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{toastError}
</div>
)}
{toastInfo && (
<div className="rounded-[var(--radius-sm)] border border-amber-500/45 bg-amber-500/10 px-3 py-2 text-sm text-amber-100 shadow-lg">
{toastInfo}
</div>
)}
{toastSuccess && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{toastSuccess}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -7,6 +7,7 @@ import Sidebar from '@/components/ui/common/Sidebar';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
@@ -32,8 +33,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
const required = getRequiredReadPermissionForPath(pathname);
if (required && !hasPermission(currentOrganization, required)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
if (required) {
const allowed =
hasPermission(currentOrganization, required) ||
(required === 'TAB_APPOINTMENTS_READ' &&
canAccessAppointmentsSection(currentOrganization));
if (!allowed) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}
}, [isAuthReady, user, currentOrganization, router, pathname]);

View File

@@ -15,7 +15,7 @@ import {
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
} from '../../../components/staff/staff-permission-form';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';

View File

@@ -1,10 +1,18 @@
'use client';
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TreatmentPage() {
const { user, currentOrganization, isAuthReady } = useAuth();
if (!isAuthReady || !user) {
return (
<div className="text-sm text-text-muted">Loading</div>
);
}
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary">
Treatment module is coming soon.
</p>
</div>
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
);
}

View File

@@ -0,0 +1,24 @@
import type { FdiToothId } from '@/types/treatment';
/** Upper arch: distal → mesial on each side (full occlusal layout left → right) */
export const FDI_UPPER_LEFT_TO_RIGHT: FdiToothId[] = [
'18', '17', '16', '15', '14', '13', '12', '11',
'21', '22', '23', '24', '25', '26', '27', '28',
];
/** Lower arch */
export const FDI_LOWER_LEFT_TO_RIGHT: FdiToothId[] = [
'48', '47', '46', '45', '44', '43', '42', '41',
'31', '32', '33', '34', '35', '36', '37', '38',
];
export type ToothShapeKind = 'incisor' | 'canine' | 'premolar' | 'molar';
/** Infer cartoon shape from FDI (last digit encodes tooth type within quadrant). */
export function getToothShapeKind(fdi: FdiToothId): ToothShapeKind {
const n = Number(fdi.slice(1));
if (n <= 2) return 'incisor';
if (n === 3) return 'canine';
if (n <= 5) return 'premolar';
return 'molar';
}

View File

@@ -0,0 +1,220 @@
'use client';
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown';
import type { AppointmentPurpose } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
formatTimeForInput,
isSameLocalCalendarDay,
} from '@/lib/appointmentTime';
interface AppointmentBookingModalProps {
open: boolean;
scheduleDate: Date;
patient: Patient | undefined;
providerUserId: string | null;
providerName: string;
initialHour: number;
onClose: () => void;
onSubmit: (payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) => Promise<void>;
loading?: boolean;
}
export function AppointmentBookingModal({
open,
scheduleDate,
patient,
providerUserId,
providerName,
initialHour,
onClose,
onSubmit,
loading = false,
}: AppointmentBookingModalProps) {
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
const [error, setError] = useState('');
const purposeTextColor =
purpose === 'consultation'
? '#ddd6fe'
: purpose === 'filling'
? '#fed7aa'
: purpose === 'endo'
? '#fecaca'
: purpose === 'visit'
? '#bae6fd'
: '#d9f99d';
useEffect(() => {
if (!open) {
return;
}
const start = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour,
0,
0,
0,
);
const end = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour < 23 ? initialHour + 1 : 23,
initialHour < 23 ? 0 : 59,
0,
0,
);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose('consultation');
setError('');
}, [open, scheduleDate, initialHour]);
if (!open || !providerUserId) {
return null;
}
const inputClass =
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
async function handleSubmit() {
setError('');
if (!providerUserId) {
return;
}
if (!patient) {
setError('Select a patient first.');
return;
}
const startAt = combineLocalDateAndTime(scheduleDate, startTime);
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
if (endAt <= startAt) {
setError('End time must be after start time.');
return;
}
const now = new Date();
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
setError('Cannot schedule in the past.');
return;
}
await onSubmit({
patientId: patient.id,
providerUserId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
purpose,
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="appointment-modal-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
New appointment
</h2>
<button
type="button"
onClick={onClose}
className="rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Close"
>
<X className="h-5 w-5 icon-flat" />
</button>
</div>
<p className="text-sm text-text-secondary">
Provider: <span className="text-text-primary font-medium">{providerName}</span>
</p>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{patient ? `${patient.firstName} ${patient.lastName}` : '—'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
<input
type="time"
step={60}
className={inputClass}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
<input
type="time"
step={60}
className={inputClass}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<Dropdown
label="Purpose"
value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
style={{ color: purposeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.consultation}
</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.filling}
</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.endo}
</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.visit}
</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }}>
{APPOINTMENT_PURPOSE_LABEL.hygiene}
</option>
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="button" variant="primary" onClick={() => void handleSubmit()} isLoading={loading}>
Save
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { Trash2 } from 'lucide-react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime';
import { purposeDeleteIconClass, purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
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);
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());
if (clipEnd <= clipStart) {
return null;
}
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
const height = ((clipEnd - clipStart) / ms) * 100;
return { top: `${top}%`, height: `${height}%` };
}
interface AppointmentScheduleGridProps {
day: Date;
providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[];
canBook: boolean;
canDelete?: boolean;
onDeleteAppointment?: (id: string) => void;
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
}
export function AppointmentScheduleGrid({
day,
providers,
appointments,
canBook,
canDelete = false,
onDeleteAppointment,
onSlotClick,
}: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX;
if (providers.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No providers available. Add staff with treatment edit access to see columns here.
</div>
);
}
return (
<div className="surface-card overflow-x-auto">
<div className="min-w-[640px]">
<div className="flex border-b border-border">
<div className="w-14 flex-shrink-0" />
{providers.map((p) => (
<div
key={p.userId}
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}
</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>
<div className="flex-1 flex min-w-0">
{providers.map((p) => (
<div
key={p.userId}
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
>
{HOURS.map((h) => {
const slotDisabled = !canBook;
return (
<button
key={h}
type="button"
disabled={slotDisabled}
title={
slotDisabled ? 'You cannot create appointments' : `Book ${formatHourLabel(h)}`
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: h * HOUR_PX, height: HOUR_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)}
/>
);
})}
{appointments
.filter((a) => a.providerUserId === p.userId)
.map((apt) => {
const pos = layoutBlock(apt, day);
if (!pos) {
return null;
}
return (
<div
key={apt.id}
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-none z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] ${purposeStyle(apt.purpose)}`}
style={{ top: pos.top, height: pos.height, minHeight: 36 }}
>
<div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left">
<p className="text-[11px] font-medium leading-tight truncate">
{apt.patient.firstName} {apt.patient.lastName}
</p>
{apt.patient.phone && (
<p className="text-[10px] opacity-90 truncate">{apt.patient.phone}</p>
)}
</div>
{canDelete && onDeleteAppointment && (
<button
type="button"
className="group pointer-events-auto shrink-0 self-center z-20 mr-2 ml-1 inline-flex cursor-pointer items-center justify-center rounded-[var(--radius-sm)] border-0 bg-transparent p-1 outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
aria-label="Delete appointment"
title="Delete appointment"
onClick={(e) => {
e.stopPropagation();
onDeleteAppointment(apt.id);
}}
>
<Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} />
</button>
)}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import {
APPOINTMENT_PURPOSE_LABEL,
APPOINTMENT_PURPOSE_LEGEND_SWATCH,
} from '@/components/ui/appointments/appointmentPurposeStyles';
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
export function AppointmentScheduleLegend() {
return (
<div className="surface-panel px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-2">Legend</p>
<div className="flex flex-wrap gap-3">
{APPOINTMENT_PURPOSES.map((p) => (
<div key={p} className="flex items-center gap-1.5 text-xs text-text-secondary">
<span
className={`inline-block h-3 w-3 rounded-sm border ${APPOINTMENT_PURPOSE_LEGEND_SWATCH[p]}`}
/>
{APPOINTMENT_PURPOSE_LABEL[p]}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps {
search: string;
onSearchChange: (value: string) => void;
patients: Patient[];
selectedPatientId?: string;
onSelectPatient: (patient: Patient) => void;
loading?: boolean;
canAddPatient: boolean;
onAddPatient: () => void;
}
export function AppointmentsPatientSearch({
search,
onSearchChange,
patients,
selectedPatientId,
onSelectPatient,
loading = false,
canAddPatient,
onAddPatient,
}: AppointmentsPatientSearchProps) {
const trimmed = search.trim();
const showAddForEmptyResults =
trimmed.length > 0 && !loading && patients.length === 0;
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="flex-1">
<Input
placeholder="Search existing patients"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
{showAddForEmptyResults && (
<Button
type="button"
variant="primary"
disabled={!canAddPatient}
onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
>
+ Add New Patient
</Button>
)}
</div>
<div className="space-y-2 max-h-72 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Searching</p>}
{!loading && trimmed.length === 0 && (
<p className="text-sm text-text-muted">Type to search patients by name, phone, or email.</p>
)}
{patients.map((patient) => {
const isSelected = selectedPatientId === patient.id;
return (
<button
key={patient.id}
type="button"
onClick={() => onSelectPatient(patient)}
className={`w-full text-left rounded-[var(--radius-sm)] border px-3 py-2 transition-colors ${
isSelected
? 'bg-primary-soft border-primary/60'
: 'border-border/60 hover:bg-background-card/70'
}`}
>
<p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName}
</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { AppointmentPurpose } from '@/types/appointment';
export const APPOINTMENT_PURPOSE_LABEL: Record<AppointmentPurpose, string> = {
consultation: 'Consultation',
filling: 'Filling',
endo: 'Endo',
visit: 'Visit',
hygiene: 'Hygiene',
};
/** Background + border for blocks / legend (matches reference palette). */
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/25 border-violet-400/50 text-violet-100',
filling: 'bg-orange-500/25 border-orange-400/50 text-orange-100',
endo: 'bg-red-500/25 border-red-400/50 text-red-100',
visit: 'bg-sky-500/25 border-sky-400/50 text-sky-100',
hygiene: 'bg-lime-500/20 border-lime-400/45 text-lime-100',
};
export function purposeStyle(purpose: string): string {
const p = purpose as AppointmentPurpose;
return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary';
}
/** Trash icon — legend hues; `!` overrides global `.lucide { color: var(--color-icon) }`. */
export function purposeDeleteIconClass(purpose: string): string {
const p = purpose as AppointmentPurpose;
const map: Record<AppointmentPurpose, string> = {
consultation: '!text-violet-400 group-hover:!text-violet-300',
filling: '!text-orange-400 group-hover:!text-orange-300',
endo: '!text-red-400 group-hover:!text-red-300',
visit: '!text-sky-400 group-hover:!text-sky-300',
hygiene: '!text-lime-600 group-hover:!text-lime-500',
};
return map[p] ?? '!text-text-muted group-hover:!text-text-secondary';
}
/** Small swatch for legend (background + border only). */
export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/85 border-violet-400/75',
filling: 'bg-orange-500/85 border-orange-400/75',
endo: 'bg-red-500/85 border-red-400/75',
visit: 'bg-sky-500/85 border-sky-400/75',
hygiene: 'bg-lime-500/80 border-lime-400/70',
};

View File

@@ -0,0 +1,68 @@
'use client';
import { ChevronDown } from 'lucide-react';
import React, { forwardRef } from 'react';
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
}
export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
({ label, error, className = '', id, children, ...props }, ref) => {
const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-text-secondary mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`
w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary/90 text-text-primary
pl-4 pr-14 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
>
{children}
</select>
<div
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
aria-hidden
>
<ChevronDown className="h-4 w-4 icon-flat" />
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-500">
{error}
</p>
)}
</div>
);
},
);
Dropdown.displayName = 'Dropdown';

View File

@@ -0,0 +1,51 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Inclusive minimum calendar day (typically today at local midnight). */
minDate: Date;
label?: string;
}
export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) {
const canGoPrev = compareLocalDayStart(value, minDate) > 0;
const labelText = value.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<div className="w-full max-w-md">
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
disabled={!canGoPrev}
onClick={() => onChange(addCalendarDays(value, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-35 disabled:pointer-events-none focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-text-primary tabular-nums px-2 py-1.5">
{labelText}
</div>
<button
type="button"
onClick={() => onChange(addCalendarDays(value, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Next day"
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@ import {
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canViewTab } from '@/shared/permissions';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions';
const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
@@ -47,7 +47,12 @@ function Sidebar() {
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => canViewTab(currentOrganization, item.read));
return withCounterpartTab.filter((item) => {
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
);

View File

@@ -0,0 +1,114 @@
'use client';
import { CalendarDays } from 'lucide-react';
import { purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import { startOfLocalDay } from '@/lib/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
interface AppointmentsStripProps {
stripHidden: boolean;
onToggleStripHidden: () => void;
selectedDay: Date;
onSelectDay: (day: Date) => void;
/** Same lower bound as Appointments schedule (cannot pick days before this). */
minScheduleDate: Date;
appointments: TreatmentAppointment[];
selectedAppointmentId: string | null;
onSelectAppointment: (id: string) => void;
loading?: boolean;
}
export function AppointmentsStrip({
stripHidden,
onToggleStripHidden,
selectedDay,
onSelectDay,
minScheduleDate,
appointments,
selectedAppointmentId,
onSelectAppointment,
loading = false,
}: AppointmentsStripProps) {
if (stripHidden) {
return (
<div className="surface-card p-3 flex items-center justify-between gap-3 flex-wrap">
<p className="text-sm text-text-secondary">Appointments are hidden.</p>
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
Show appointments
</button>
</div>
);
}
return (
<div className="surface-card p-4 space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
<h2 className="text-sm font-semibold text-text-primary truncate">My appointments</h2>
</div>
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
Hide appointments
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={selectedDay}
minDate={minScheduleDate}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
{loading && (
<p className="text-sm text-text-muted pb-2">Loading appointments</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{appointments.length === 0 && (
<p className="text-sm text-text-muted">No appointments assigned to you on this day.</p>
)}
{appointments.map((a) => {
const sel = a.id === selectedAppointmentId;
const start = new Date(a.startAt);
const end = new Date(a.endAt);
const timeLabel = `${start.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})} ${end.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}`;
const palette = purposeStyle(a.purpose);
return (
<button
key={a.id}
type="button"
onClick={() => onSelectAppointment(a.id)}
className={`
text-left rounded-[var(--radius-sm)] border px-3 py-2 min-w-[200px] max-w-[280px] transition-shadow min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${palette}
${sel ? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]' : 'hover:brightness-110'}
`}
>
<p className="text-[11px] font-medium tabular-nums opacity-95">{timeLabel}</p>
<p className="text-sm font-medium leading-tight truncate mt-0.5">
{a.patientFirstName} {a.patientLastName}
</p>
<p className="text-[11px] opacity-90 capitalize mt-0.5">{a.purpose}</p>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
'use client';
import { useId } from 'react';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { ToothGlyph } from '@/components/ui/treatment/ToothGlyph';
import type { FdiToothId } from '@/types/treatment';
const TOOTH_NUMBER_GAP = 'mt-1';
/** Six teeth each side of the midline (anteriors): slightly larger glyph + wrapper. */
const MIDLINE_SIX = new Set<FdiToothId>([
'13', '12', '11', '21', '22', '23',
'43', '42', '41', '31', '32', '33',
]);
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
return q === '2' || q === '3';
}
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
onToggle: (fdi: FdiToothId) => void;
disabled?: boolean;
}
export function FdiToothChart({ selected, onToggle, disabled }: FdiToothChartProps) {
const uid = useId().replace(/:/g, '');
const archPeak = 10;
const TOOTH_TWEAKS: Record<FdiToothId, { glyph: string; offset: number; rotate: number }> = {
'18': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: -11 },
'17': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: -8 },
'16': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: -6 },
'15': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: -4 },
'14': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: -2 },
'13': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: -1 },
'12': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: -1 },
'11': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: -0.5 },
'21': { glyph: 'w-[1.8rem] h-[5.08rem]', offset: 7, rotate: 0.5 },
'22': { glyph: 'w-[1.8rem] h-[5.02rem]', offset: 7, rotate: 1 },
'23': { glyph: 'w-[1.9rem] h-[5.28rem]', offset: 8, rotate: 1 },
'24': { glyph: 'w-[1.8rem] h-[4.85rem]', offset: 9, rotate: 2 },
'25': { glyph: 'w-[1.8rem] h-[4.8rem]', offset: 9, rotate: 4 },
'26': { glyph: 'w-[2.1rem] h-[4.95rem]', offset: 9, rotate: 6 },
'27': { glyph: 'w-[2rem] h-[4.75rem]', offset: 8, rotate: 8 },
'28': { glyph: 'w-[1.98rem] h-[4.65rem]', offset: 7, rotate: 11 },
'48': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: -11 },
'47': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: -8 },
'46': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: -6 },
'45': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: -4 },
'44': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: -2 },
'43': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: -1 },
'42': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: -1 },
'41': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: -0.5 },
'31': { glyph: 'w-[1.74rem] h-[4.98rem]', offset: -7, rotate: 0.5 },
'32': { glyph: 'w-[1.76rem] h-[5.04rem]', offset: -7, rotate: 1 },
'33': { glyph: 'w-[1.88rem] h-[5.34rem]', offset: -8, rotate: 1 },
'34': { glyph: 'w-[1.82rem] h-[4.9rem]', offset: -9, rotate: 2 },
'35': { glyph: 'w-[1.82rem] h-[4.85rem]', offset: -9, rotate: 4 },
'36': { glyph: 'w-[2.15rem] h-[5rem]', offset: -9, rotate: 6 },
'37': { glyph: 'w-[2rem] h-[4.8rem]', offset: -8, rotate: 8 },
'38': { glyph: 'w-[1.98rem] h-[4.7rem]', offset: -7, rotate: 11 },
};
const toothSizeClass = (fdi: FdiToothId) => {
const kind = getToothShapeKind(fdi);
const mid = MIDLINE_SIX.has(fdi);
switch (kind) {
case 'incisor':
return mid
? { wrapper: 'w-[2.1rem]', glyph: 'w-[1.68rem] h-[4.78rem]' }
: { wrapper: 'w-[2rem]', glyph: 'w-[1.6rem] h-[4.6rem]' };
case 'canine':
return mid
? { wrapper: 'w-[2.2rem]', glyph: 'w-[1.78rem] h-[5.05rem]' }
: { wrapper: 'w-[2.1rem]', glyph: 'w-[1.7rem] h-[4.9rem]' };
case 'premolar':
return mid
? { wrapper: 'w-[2.34rem]', glyph: 'w-[1.93rem] h-[5.02rem]' }
: { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' };
case 'molar':
return mid
? { wrapper: 'w-[2.52rem]', glyph: 'w-[2.12rem] h-[5.1rem]' }
: { wrapper: 'w-[2.45rem]', glyph: 'w-[2.05rem] h-[4.95rem]' };
default:
return { wrapper: 'w-[2.25rem]', glyph: 'w-[1.85rem] h-[4.85rem]' };
}
};
const archOffset = (index: number, count: number, upper?: boolean) => {
const center = (count - 1) / 2;
const dist = Math.abs(index - center) / center;
const curve = (1 - Math.pow(dist, 1.45)) * archPeak;
return upper ? curve : -curve;
};
const archRotate = (index: number, count: number) => {
const center = (count - 1) / 2;
const normalized = (index - center) / center;
return normalized * 6;
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-wrap justify-center gap-x-1 gap-y-1">
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
const isSel = selected.has(fdi);
const size = toothSizeClass(fdi);
const tweak = TOOTH_TWEAKS[fdi];
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
<button
type="button"
disabled={disabled}
onClick={() => onToggle(fdi)}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={`FDI tooth ${fdi}${isSel ? ', selected' : ''}`}
>
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
/>
</button>
</div>
</div>
);
})}
</div>
);
return (
<div className="surface-card p-4 space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">FDI tooth chart</h3>
<p className="text-xs text-text-muted mt-0.5">
Tap teeth to multi-select (FDI). Selection applies to the active treatment record until you save.
</p>
</div>
<div className="flex flex-col gap-2 sm:items-end w-full sm:max-w-xs">
<p className="text-xs text-text-secondary tabular-nums text-right">
Selected: {selected.size === 0 ? '—' : [...selected].sort().join(', ')}
</p>
</div>
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">Upper arch</p>
<div className="relative isolate py-1">
<div
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
aria-hidden
/>
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex justify-center gap-x-1 ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div
className="my-4 h-px w-full max-w-[min(100%,42rem)] mx-auto bg-border/70"
role="separator"
aria-hidden
/>
<div className="my-4 pt-1">
<div className="flex justify-center gap-x-1">
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${size.wrapper} ${
isSel ? 'text-primary' : 'text-text-muted'
}`}
>
{fdi}
</span>
);
})}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
</div>
</div>
</div>
</div>
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">Lower arch</p>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import { FileText } from 'lucide-react';
import type { PastTreatment } from '@/types/treatment';
interface PastTreatmentsPanelProps {
items: PastTreatment[];
loading?: boolean;
selectedTreatmentId?: string | null;
onSelectTreatment?: (treatment: PastTreatment) => void;
}
export function PastTreatmentsPanel({
items,
loading,
selectedTreatmentId,
onSelectTreatment,
}: PastTreatmentsPanelProps) {
return (
<div className="surface-card p-4 space-y-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">Previous treatments</h3>
<p className="text-xs text-text-muted mt-0.5">
Document preview is not implemented yet; file names are listed for context.
</p>
</div>
{loading && <p className="text-sm text-text-muted">Loading history...</p>}
{!loading && items.length === 0 && (
<p className="text-sm text-text-muted">No prior treatments for this patient.</p>
)}
<div className="space-y-3 max-h-[min(420px,55vh)] overflow-y-auto pr-1">
{items.map((t) => (
<article
key={t.id}
className={`border rounded-[var(--radius-md)] p-3 ${
selectedTreatmentId === t.id
? 'border-primary/70 bg-primary-soft/35'
: 'border-border/70 bg-background-secondary/40'
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{t.title}</p>
<time className="text-xs text-text-muted tabular-nums shrink-0" dateTime={t.treatmentAt}>
{new Date(t.treatmentAt).toLocaleDateString()}
</time>
</div>
<p className="text-xs text-text-secondary mt-1">Status: {t.status}</p>
{t.records.length > 0 && (
<ul className="mt-2 space-y-1.5 text-xs text-text-secondary">
{t.records.map((r) => (
<li key={r.id}>
<span className="text-text-primary font-medium">Record: </span>
<span className="capitalize">{r.treatmentType}</span>
{' | '}
{r.teeth.length > 0 ? `Teeth ${[...r.teeth].sort().join(', ')}` : 'No teeth tagged'}
{r.notes ? `${r.notes}` : ''}
</li>
))}
</ul>
)}
{onSelectTreatment && (
<div className="mt-3 pt-2 border-t border-border/50 flex justify-end">
<button
type="button"
onClick={() => onSelectTreatment(t)}
className="text-xs text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 rounded-[var(--radius-sm)] px-1"
>
Review details
</button>
</div>
)}
{t.documents.length > 0 && (
<div className="mt-3 pt-2 border-t border-border/50">
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1.5">Attachments</p>
<ul className="space-y-1">
{t.documents.map((doc) => (
<li
key={doc.id}
className="flex items-center gap-2 text-xs text-text-secondary"
>
<FileText className="w-3.5 h-3.5 shrink-0 icon-flat" aria-hidden />
<span className="truncate">{doc.fileName}</span>
<span className="text-text-muted tabular-nums shrink-0">
{(doc.sizeBytes / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</div>
)}
</article>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { memo, type ReactNode } from 'react';
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
import { getToothPathModel, type ToothPathModel } from '@/components/ui/treatment/toothPathModel';
interface ToothGlyphProps {
fdi: FdiToothId;
kind: ToothShapeKind;
gradientId: string;
selected: boolean;
upper?: boolean;
mirrored?: boolean;
upsideDown?: boolean;
className?: string;
}
function filledSilhouette(
model: ToothPathModel,
crownFill: string,
rootFill: string,
stroke: string,
strokeW: number,
): ReactNode {
return (
<>
<path d={model.crown} fill={crownFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
{model.roots.map((d, i) => (
<path key={i} d={d} fill={rootFill} stroke={stroke} strokeWidth={strokeW} strokeLinejoin="round" />
))}
</>
);
}
function detailOnly(model: ToothPathModel, stroke: string, strokeW: number): ReactNode {
if (!model.detailStroke) return null;
const { d, widthScale } = model.detailStroke;
return <path d={d} fill="none" stroke={stroke} strokeWidth={strokeW * widthScale} strokeLinecap="round" />;
}
function renderClinicalFill(model: ToothPathModel, selected: boolean, gradientId: string): ReactNode {
const stroke = selected ? 'var(--color-primary)' : '#1e293b';
const strokeW = selected ? 2.4 : 1.35;
const fill = `url(#${gradientId})`;
return (
<>
{filledSilhouette(model, fill, fill, stroke, strokeW)}
{detailOnly(model, stroke, strokeW)}
</>
);
}
/** FDI chart tooth: clinical gradient + roots only. */
export const ToothGlyph = memo(function ToothGlyph({
fdi,
kind,
gradientId,
selected,
upper,
mirrored,
upsideDown,
className = 'w-8 h-[4.85rem]',
}: ToothGlyphProps) {
const model: ToothPathModel | null = getToothPathModel(fdi, kind, upper);
const filter = selected ? 'drop-shadow(0 0 6px rgba(9, 169, 188, 0.65))' : undefined;
if (!model) {
return (
<svg viewBox="0 0 36 78" className={`${className} shrink-0`} aria-hidden>
<text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
?
</text>
</svg>
);
}
const body = renderClinicalFill(model, selected, gradientId);
return (
<svg
viewBox="0 0 36 78"
className={`${className} shrink-0`}
style={{ filter }}
aria-hidden
>
<defs>
<radialGradient id={gradientId} cx="45%" cy="35%" r="65%">
<stop offset="0%" stopColor={selected ? '#cffafe' : '#ffffff'} />
<stop offset="55%" stopColor={selected ? '#5eead4' : '#e0f2fe'} />
<stop offset="100%" stopColor={selected ? '#0e7490' : '#93c5fd'} />
</radialGradient>
</defs>
<g
transform={[
mirrored ? 'translate(36 0) scale(-1 1)' : '',
upsideDown ? 'translate(0 78) scale(1 -1)' : '',
]
.filter(Boolean)
.join(' ') || undefined}
>
{body}
</g>
</svg>
);
});

View File

@@ -0,0 +1,656 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
import { PastTreatmentsPanel } from '@/components/ui/treatment/PastTreatmentsPanel';
import { Checkbox } from '@/components/ui/common/Checkbox';
import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
import {
fetchLinkedOrganizations,
fetchMyAppointmentsForDay,
fetchPastTreatments,
saveTreatmentDraft,
sendTreatmentRecord,
} from '@/lib/mocks/treatmentMockApi';
import { pickAutoAppointment } from '@/lib/treatmentSelection';
import { canEditTreatment } from '@/shared/permissions';
import type { Organization } from '@/types/organization';
import type {
FdiToothId,
LinkedOrganizationOption,
PastTreatment,
TreatmentAppointment,
TreatmentAttachmentMeta,
TreatmentRecordDraft,
} from '@/types/treatment';
function newRecord(): TreatmentRecordDraft {
return {
clientId:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
treatmentType: 'consultation',
teeth: [],
comment: '',
attachmentMetas: [],
sendToOrganizationIds: [],
sentAt: null,
};
}
interface TreatmentWorkspaceProps {
userId: string;
currentOrganization: Organization | null;
}
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
const canEdit = canEditTreatment(currentOrganization);
const [stripHidden, setStripHidden] = useState(false);
const scheduleMinDate = useMemo(() => startOfLocalDay(new Date()), []);
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
const [apptsLoading, setApptsLoading] = useState(false);
const [selectionLocked, setSelectionLocked] = useState(false);
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
const [history, setHistory] = useState<PastTreatment[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [orgs, setOrgs] = useState<LinkedOrganizationOption[]>([]);
const [records, setRecords] = useState<TreatmentRecordDraft[]>(() => [newRecord()]);
const [activeRecordId, setActiveRecordId] = useState<string>(() => records[0].clientId);
const selectionLockedRef = useRef(selectionLocked);
selectionLockedRef.current = selectionLocked;
const attachmentInputRef = useRef<HTMLInputElement>(null);
const [saveBusy, setSaveBusy] = useState(false);
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
const [banner, setBanner] = useState<string | null>(null);
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [reviewTreatment, setReviewTreatment] = useState<PastTreatment | null>(null);
const selectedAppointment = useMemo(
() => appointments.find((a) => a.id === selectedAppointmentId) ?? null,
[appointments, selectedAppointmentId],
);
const activeRecord = useMemo(
() => records.find((r) => r.clientId === activeRecordId) ?? records[0],
[records, activeRecordId],
);
const selectedTeethSet = useMemo(() => new Set(activeRecord.teeth), [activeRecord.teeth]);
const activeLinkedOrganizations = useMemo(() => orgs.filter((o) => o.active), [orgs]);
const filteredOrganizations = useMemo(() => {
const q = organizationSearch.trim().toLowerCase();
if (!q) return activeLinkedOrganizations;
return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q));
}, [organizationSearch, activeLinkedOrganizations]);
const recentOrganizations = useMemo(() => {
if (recentOrganizationIds.length === 0) return [];
const recentSet = new Set(recentOrganizationIds);
return activeLinkedOrganizations
.filter((o) => recentSet.has(o.id))
.sort((a, b) => recentOrganizationIds.indexOf(a.id) - recentOrganizationIds.indexOf(b.id))
.slice(0, 3);
}, [recentOrganizationIds, activeLinkedOrganizations]);
const currentDraftPreview = useMemo<PastTreatment | null>(() => {
if (!selectedAppointment) return null;
return {
id: 'current-draft',
patientId: selectedAppointment.patientId,
title: `Current draft for ${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`,
treatmentAt: new Date().toISOString(),
status: 'draft',
records: records.map((r, idx) => ({
id: r.clientId || `draft-${idx + 1}`,
treatmentType: r.treatmentType,
teeth: r.teeth,
notes: r.comment || null,
})),
documents: records.flatMap((r) => r.attachmentMetas),
};
}, [records, selectedAppointment]);
const treatmentTypeTextColor = useMemo(() => {
const map: Record<TreatmentRecordDraft['treatmentType'], string> = {
consultation: '#ddd6fe',
filling: '#fed7aa',
endo: '#fecaca',
visit: '#bae6fd',
hygiene: '#d9f99d',
};
return map[activeRecord.treatmentType];
}, [activeRecord.treatmentType]);
useEffect(() => {
setSelectionLocked(false);
}, [selectedDay]);
useEffect(() => {
let cancelled = false;
setApptsLoading(true);
void (async () => {
try {
const list = await fetchMyAppointmentsForDay(userId, selectedDay);
if (cancelled) return;
setAppointments(list);
if (!selectionLockedRef.current) {
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
}
} finally {
if (!cancelled) setApptsLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [userId, selectedDay]);
useEffect(() => {
const today = startOfLocalDay(new Date());
if (!isSameLocalCalendarDay(selectedDay, today) || selectionLocked) return;
const id = window.setInterval(() => {
setSelectedAppointmentId((prev) => {
const next = pickAutoAppointment(appointments, selectedDay);
return next ?? prev;
});
}, 60_000);
return () => window.clearInterval(id);
}, [selectedDay, appointments, selectionLocked]);
useEffect(() => {
let cancelled = false;
void (async () => {
const list = await fetchLinkedOrganizations();
if (!cancelled) setOrgs(list);
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!selectedAppointment) {
setHistory([]);
return;
}
let cancelled = false;
setHistoryLoading(true);
void (async () => {
const items = await fetchPastTreatments(selectedAppointment.patientId);
if (!cancelled) {
setHistory(items);
setHistoryLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedAppointment?.patientId]);
useEffect(() => {
const first = newRecord();
setRecords([first]);
setActiveRecordId(first.clientId);
setOrganizationSearch('');
setReviewTreatment(null);
}, [selectedAppointment?.id]);
const fixActiveAfterRecordsChange = useCallback((next: TreatmentRecordDraft[]) => {
setRecords(next);
setActiveRecordId((id) => (next.some((r) => r.clientId === id) ? id : next[0].clientId));
}, []);
const toggleTooth = useCallback(
(fdi: FdiToothId) => {
if (!canEdit) return;
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId) return r;
const set = new Set(r.teeth);
if (set.has(fdi)) set.delete(fdi);
else set.add(fdi);
return { ...r, teeth: [...set].sort() as FdiToothId[] };
}),
);
},
[activeRecordId, canEdit],
);
const onPickAppointment = useCallback((id: string) => {
setSelectionLocked(true);
setSelectedAppointmentId(id);
}, []);
const addAttachments = useCallback(
(files: FileList | null) => {
if (!files?.length || !canEdit) return;
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId) return r;
const added: TreatmentAttachmentMeta[] = [...r.attachmentMetas];
for (let i = 0; i < files.length; i++) {
const f = files[i];
added.push({
id: `local-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 7)}`,
fileName: f.name,
mimeType: f.type || 'application/octet-stream',
sizeBytes: f.size,
});
}
return { ...r, attachmentMetas: added };
}),
);
},
[activeRecordId, canEdit],
);
const handleSaveAll = useCallback(async () => {
if (!canEdit || !selectedAppointment) return;
setSaveBusy(true);
setBanner(null);
try {
await saveTreatmentDraft({
appointmentId: selectedAppointment.id,
patientId: selectedAppointment.patientId,
records: records.map(({ clientId: _c, sentAt: _s, ...rest }) => rest),
});
setBanner('Treatment draft saved (mock). You can send records later.');
} finally {
setSaveBusy(false);
}
}, [canEdit, selectedAppointment, records]);
const handleSendRecord = useCallback(
async (record: TreatmentRecordDraft) => {
if (!canEdit || !selectedAppointment) return;
const targets = record.sendToOrganizationIds.filter((id) =>
orgs.some((o) => o.id === id && o.active),
);
if (targets.length === 0) {
setBanner('Choose at least one active organization to send this record.');
return;
}
setSendBusyId(record.clientId);
setBanner(null);
try {
await sendTreatmentRecord({
appointmentId: selectedAppointment.id,
patientId: selectedAppointment.patientId,
recordClientId: record.clientId,
organizationIds: targets,
});
setRecords((prev) =>
prev.map((r) =>
r.clientId === record.clientId ? { ...r, sentAt: new Date().toISOString() } : r,
),
);
setRecentOrganizationIds((prev) => {
const next = [...record.sendToOrganizationIds.filter((id) => id && !prev.includes(id)), ...prev];
return next.slice(0, 10);
});
setBanner('Record sent to selected organizations (mock).');
} finally {
setSendBusyId(null);
}
},
[canEdit, selectedAppointment, orgs],
);
if (!canEdit) {
return (
<div className="surface-card p-6 max-w-xl">
<h2 className="text-lg font-semibold text-text-primary">Treatment workspace</h2>
<p className="text-sm text-text-secondary mt-2">
Your role can view the Treatment tab, but editing clinical workflows requires{' '}
<span className="text-text-primary font-medium">Treatment edit</span> permission.
</p>
</div>
);
}
return (
<div className="space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary">
Mocked data appointments, history, save, and send are simulated until backend endpoints exist.
</p>
</header>
<AppointmentsStrip
stripHidden={stripHidden}
onToggleStripHidden={() => setStripHidden((s) => !s)}
selectedDay={selectedDay}
onSelectDay={setSelectedDay}
minScheduleDate={scheduleMinDate}
appointments={appointments}
selectedAppointmentId={selectedAppointmentId}
onSelectAppointment={onPickAppointment}
loading={apptsLoading}
/>
{banner && (
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-4 py-2 text-sm text-text-primary">
{banner}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
<div className="space-y-4">
{selectedAppointment ? (
<div className="surface-card p-4 space-y-1">
<p className="text-xs uppercase tracking-wide text-text-muted">Selected patient</p>
<p className="text-lg font-semibold text-text-primary">
{selectedAppointment.patientFirstName} {selectedAppointment.patientLastName}
</p>
<p className="text-xs text-text-secondary">
Appointment purpose:{' '}
<span className="capitalize text-text-primary">{selectedAppointment.purpose}</span>
</p>
</div>
) : (
<div className="surface-card p-4 text-sm text-text-muted">
{apptsLoading ? 'Loading appointments…' : 'Select a day with at least one appointment.'}
</div>
)}
<PastTreatmentsPanel
items={history}
loading={historyLoading}
selectedTreatmentId={reviewTreatment?.id ?? null}
onSelectTreatment={setReviewTreatment}
/>
<div className="surface-card p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-text-primary">Treatment review</h3>
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
onClick={() => setReviewTreatment(currentDraftPreview)}
>
Preview current treatment
</Button>
</div>
{!reviewTreatment && (
<p className="text-sm text-text-muted">
Select a treatment from history, or preview the current draft.
</p>
)}
{reviewTreatment && (
<div className="border border-border/70 rounded-[var(--radius-md)] p-3 bg-background-secondary/40 space-y-2">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-text-primary">{reviewTreatment.title}</p>
<span className="text-xs text-text-muted tabular-nums">
{new Date(reviewTreatment.treatmentAt).toLocaleDateString()}
</span>
</div>
<p className="text-xs text-text-secondary capitalize">Status: {reviewTreatment.status}</p>
<div className="space-y-2">
{reviewTreatment.records.map((r, idx) => (
<div key={r.id} className="rounded-[var(--radius-sm)] border border-border/60 px-2.5 py-2">
<p className="text-xs text-text-primary font-medium">Record {idx + 1}</p>
<p className="text-xs text-text-secondary capitalize mt-1">
Type: {r.treatmentType}
</p>
<p className="text-xs text-text-secondary">
Teeth: {r.teeth.length ? [...r.teeth].sort().join(', ') : 'None selected'}
</p>
{r.notes && <p className="text-xs text-text-muted mt-1">Notes: {r.notes}</p>}
</div>
))}
</div>
<p className="text-xs text-text-muted">
Attachments: {reviewTreatment.documents.length}
</p>
</div>
)}
</div>
</div>
<div className="space-y-4 min-w-0">
<FdiToothChart
selected={selectedTeethSet}
onToggle={toggleTooth}
disabled={!selectedAppointment}
/>
<div className="surface-card p-4 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-primary">Treatment records</h3>
<p className="text-xs text-text-muted mt-0.5">
Each record has its own teeth, notes, attachments, and destinations for send.
</p>
</div>
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
onClick={() => {
const nr = newRecord();
fixActiveAfterRecordsChange([...records, nr]);
setActiveRecordId(nr.clientId);
}}
>
Add record
</Button>
</div>
<div className="flex flex-wrap gap-2">
{records.map((r, idx) => (
<button
key={r.clientId}
type="button"
onClick={() => setActiveRecordId(r.clientId)}
className={`
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
${
r.clientId === activeRecordId
? 'border-primary bg-primary-soft font-medium text-text-primary'
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
}
`}
>
Record {idx + 1}
{r.sentAt ? ' · sent' : ''}
</button>
))}
</div>
{activeRecord && (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
<label className="block text-xs font-medium text-text-secondary">
Comments
<textarea
value={activeRecord.comment}
onChange={(e) => {
const v = e.target.value;
setRecords((prev) =>
prev.map((r) =>
r.clientId === activeRecordId ? { ...r, comment: v } : r,
),
);
}}
placeholder="Write clinical notes for this record…"
rows={5}
disabled={!selectedAppointment}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
/>
</label>
<div>
<Dropdown
label="Treatment type"
value={activeRecord.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentRecordDraft['treatmentType'];
setRecords((prev) =>
prev.map((r) =>
r.clientId === activeRecordId ? { ...r, treatmentType: nextType } : r,
),
);
}}
disabled={!selectedAppointment}
className="capitalize"
style={{ color: treatmentTypeTextColor }}
>
<option value="consultation" style={{ color: '#ddd6fe', backgroundColor: '#14253d' }} className="capitalize">consultation</option>
<option value="filling" style={{ color: '#fed7aa', backgroundColor: '#14253d' }} className="capitalize">filling</option>
<option value="endo" style={{ color: '#fecaca', backgroundColor: '#14253d' }} className="capitalize">endo</option>
<option value="visit" style={{ color: '#bae6fd', backgroundColor: '#14253d' }} className="capitalize">visit</option>
<option value="hygiene" style={{ color: '#d9f99d', backgroundColor: '#14253d' }} className="capitalize">hygiene</option>
</Dropdown>
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">Attachments (mock)</p>
<input
ref={attachmentInputRef}
id="treatment-record-attachments"
type="file"
multiple
disabled={!selectedAppointment}
onChange={(e) => {
addAttachments(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label="Attach files for this treatment record"
/>
<Button
type="button"
variant="primary"
disabled={!selectedAppointment}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-record-attachments"
>
Choose files
</Button>
{activeRecord.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeRecord.attachmentMetas.map((f) => (
<li key={f.id} className="truncate">
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
Send this record to linked organizations
</p>
<div className="space-y-2 mb-2">
<SearchBar
value={organizationSearch}
onChange={setOrganizationSearch}
placeholder="Search active organizations..."
/>
{recentOrganizations.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted">Recent:</span>
{recentOrganizations.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId || r.sentAt) return r;
if (r.sendToOrganizationIds.includes(o.id)) return r;
return { ...r, sendToOrganizationIds: [...r.sendToOrganizationIds, o.id] };
}),
);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
>
{o.name}
</button>
))}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{filteredOrganizations.map((o) => (
<Checkbox
key={o.id}
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
disabled={!selectedAppointment || Boolean(activeRecord.sentAt)}
onChange={(checked) => {
setRecords((prev) =>
prev.map((r) => {
if (r.clientId !== activeRecordId) return r;
const next = new Set(r.sendToOrganizationIds);
if (checked) next.add(o.id);
else next.delete(o.id);
return { ...r, sendToOrganizationIds: [...next] };
}),
);
}}
label={`${o.name}${o.active ? '' : ' (inactive)'}`}
/>
))}
{filteredOrganizations.length === 0 && (
<p className="text-xs text-text-muted">No active organization matches your search.</p>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="primary"
disabled={!selectedAppointment || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
isLoading={sendBusyId === activeRecord.clientId}
onClick={() => void handleSendRecord(activeRecord)}
>
Send this record
</Button>
{activeRecord.sentAt && (
<span className="text-xs text-text-muted">
Sent at {new Date(activeRecord.sentAt).toLocaleString()}
</span>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2 border-t border-border/60">
<Button
type="button"
variant="primary"
disabled={!selectedAppointment || saveBusy}
isLoading={saveBusy}
onClick={() => void handleSaveAll()}
>
Save treatment draft
</Button>
<p className="text-xs text-text-muted self-center">
Saving stores all records locally (mock). Sending is per record and can happen after save.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import type { FdiToothId } from '@/types/treatment';
import type { ToothShapeKind } from '@/components/treatment/fdiToothMeta';
export interface ToothPathModel {
crown: string;
roots: string[];
/** Occlusal groove etc. — open path, stroke only */
detailStroke?: { d: string; widthScale: number };
}
/** FDI-specific closed paths for the clinical (gradient + roots) chart. */
export function getToothPathModel(fdi: FdiToothId, kind: ToothShapeKind, upper?: boolean): ToothPathModel | null {
const lastDigit = Number(fdi[1]);
const isThirdMolar = lastDigit === 8;
const isSecondMolar = lastDigit === 7;
const isFirstMolar = lastDigit === 6;
const isCentralIncisor = lastDigit === 1;
const isLateralIncisor = lastDigit === 2;
switch (kind) {
case 'incisor':
if (isLateralIncisor) {
return {
crown: 'M12 9 C14 6, 22 6, 24 9 C26 12, 26 25, 23 30 C21 32, 15 32, 13 30 C10 25, 10 12, 12 9 Z',
roots: ['M15.5 30 C14.5 40, 14.5 51, 16 60 C17 65, 19 65, 20 60 C21.5 51, 21.5 40, 20.5 30 Z'],
};
}
return {
crown: 'M11 8 C13 5, 23 5, 25 8 C27 12, 27 26, 24 31 C22 33, 14 33, 12 31 C9 26, 9 12, 11 8 Z',
roots: ['M15 31 C14 42, 14 53, 16 63 C17 68, 19 68, 20 63 C22 53, 22 42, 21 31 Z'],
};
case 'canine':
return {
crown: 'M18 6 C22 6, 25 10, 26 15 C26 20, 24 30, 22 33 C20 35, 16 35, 14 33 C12 30, 10 20, 10 15 C11 10, 14 6, 18 6 Z',
roots: ['M16 33 C14 44, 13 55, 14 66 C15 72, 18 75, 20 66 C21 55, 20 44, 20 33 Z'],
};
case 'premolar':
if (upper && lastDigit === 4) {
return {
crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 25, 26 31 C24 34, 12 34, 10 31 C7 25, 7 16, 9 12 Z',
roots: [
'M13 31 C12 39, 12 48, 13 56 C14 62, 16 64, 17 58 C18 54, 18 48, 18 41 Z',
'M23 31 C24 39, 24 48, 23 56 C22 62, 20 64, 19 58 C18 54, 18 48, 18 41 Z',
],
};
}
return {
crown: 'M9 12 C11 8, 15 7, 18 9 C21 7, 25 8, 27 12 C29 16, 29 26, 26 31 C24 34, 12 34, 10 31 C7 26, 7 16, 9 12 Z',
roots: ['M16 31 C15 42, 15 53, 16 62 C17 67, 19 67, 20 62 C21 53, 21 42, 20 31 Z'],
};
case 'molar':
if (isThirdMolar) {
const crown =
'M9 15 C11 11, 14 10, 17 11 C20 9, 24 10, 26 14 C28 18, 28 26, 25 31 C23 33, 11 33, 9 31 C6 26, 6 19, 9 15 Z';
if (upper) {
return {
crown,
roots: [
'M14 32 C13 39, 13 47, 14 54 C15 59, 17 61, 18 55 C19 50, 19 41, 18 33 Z',
'M22 32 C23 39, 23 47, 22 54 C21 59, 19 61, 18 55 C17 50, 17 41, 18 33 Z',
],
};
}
return {
crown,
roots: [
'M14 32 C12 39, 12 47, 13 55 C14 61, 16 64, 18 57 C19 52, 19 44, 18 36 Z',
'M22 32 C24 39, 24 47, 23 55 C22 61, 20 64, 18 57 C17 52, 17 44, 18 36 Z',
],
};
}
if (isSecondMolar && upper) {
return {
crown: 'M8 14 C10 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 29 9, 30 14 C31 18, 31 27, 28 32 C26 35, 10 35, 8 32 C5 27, 5 18, 8 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
],
};
}
if (isFirstMolar && !upper) {
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
],
detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
};
}
if (upper) {
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 57 C11 62, 13 64, 14 58 C15 52, 15 44, 14 36 Z',
'M18 35 C17 44, 17 52, 18 60 C19 66, 20 66, 21 60 C22 52, 22 44, 21 35 Z',
'M25 35 C27 43, 27 50, 26 57 C25 62, 23 64, 22 58 C21 52, 21 44, 22 36 Z',
],
};
}
return {
crown: 'M7 14 C9 9, 13 8, 16 10 C18 8, 21 8, 23 10 C26 8, 30 9, 31 14 C32 18, 32 27, 29 32 C27 35, 9 35, 7 32 C4 27, 4 18, 7 14 Z',
roots: [
'M11 35 C9 43, 9 50, 10 58 C11 64, 14 67, 16 60 C17 56, 17 50, 17 44 Z',
'M25 35 C27 43, 27 50, 26 58 C25 64, 22 67, 20 60 C19 56, 19 50, 19 44 Z',
],
detailStroke: { d: 'M15 61 Q18 66 21 61', widthScale: 0.85 },
};
default:
return null;
}
}

View File

@@ -0,0 +1,32 @@
import { apiClient } from './client';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
export interface CreateAppointmentBody {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: string;
}
export const appointmentsApi = {
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const response = await apiClient.get('/appointments/column-providers');
return response.data;
},
list: async (params: { from: string; to: string }): Promise<{ success: boolean; data: AppointmentRecord[] }> => {
const response = await apiClient.get('/appointments', { params });
return response.data;
},
create: async (body: CreateAppointmentBody): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.post('/appointments', body);
return response.data;
},
remove: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/appointments/${id}`);
return response.data;
},
};

View File

@@ -0,0 +1,63 @@
/** Normalize to local midnight; invalid input falls back to today. */
export function startOfLocalDay(d: Date): Date {
if (Number.isNaN(d.getTime())) {
const t = new Date();
return new Date(t.getFullYear(), t.getMonth(), t.getDate(), 0, 0, 0, 0);
}
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/** Local calendar bounds for a date (browser timezone). */
export function getLocalDayIsoRange(day: Date): { from: string; to: string } {
const start = startOfLocalDay(day);
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);
return { from: start.toISOString(), to: end.toISOString() };
}
export function toDateInputValue(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseDateInput(value: string): Date {
const [y, m, d] = value.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0);
}
export function combineLocalDateAndTime(day: Date, timeHHmm: string): Date {
const [h, min] = timeHHmm.split(':').map(Number);
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, min, 0, 0);
}
export function formatTimeForInput(d: Date): string {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function formatHourLabel(hour: number): string {
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
}
/** Local midnight + delta calendar days. */
export function addCalendarDays(day: Date, delta: number): Date {
return new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta, 0, 0, 0, 0);
}
/** Compare two calendar days at local midnight (ordering by date only). */
export function compareLocalDayStart(a: Date, b: Date): number {
const ta = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
return ta - tb;
}

View File

@@ -0,0 +1,12 @@
export function formatApiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'message' in err) {
const m = (err as { message: unknown }).message;
if (Array.isArray(m)) {
return m.filter(Boolean).join(', ');
}
if (typeof m === 'string' && m.trim()) {
return m;
}
}
return fallback;
}

View File

@@ -0,0 +1,200 @@
import {
addCalendarDays,
isSameLocalCalendarDay,
startOfLocalDay,
} from '@/lib/appointmentTime';
import type {
FdiToothId,
LinkedOrganizationOption,
PastTreatment,
SaveTreatmentPayload,
SendTreatmentRecordPayload,
TreatmentAppointment,
} from '@/types/treatment';
const MOCK_DELAY_MS = 280;
function sleep(ms = MOCK_DELAY_MS) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
function pad2(n: number) {
return String(n).padStart(2, '0');
}
/** Builds deterministic ids so React keys stay stable across hot reloads */
function mockApptId(dayKey: string, slotIndex: number | string) {
return `mock-appt-${dayKey}-${slotIndex}`;
}
function buildSlotsForDay(day: Date, userId: string): TreatmentAppointment[] {
const dayStart = startOfLocalDay(day);
const y = dayStart.getFullYear();
const m = dayStart.getMonth();
const d = dayStart.getDate();
const dayKey = `${y}-${pad2(m + 1)}-${pad2(d)}`;
const base: Omit<TreatmentAppointment, 'id' | 'startAt' | 'endAt'>[] = [
{
patientId: 'pat-alice',
patientFirstName: 'Alice',
patientLastName: 'Moradi',
providerUserId: userId,
purpose: 'consultation',
},
{
patientId: 'pat-babak',
patientFirstName: 'Babak',
patientLastName: 'Karimi',
providerUserId: userId,
purpose: 'filling',
},
{
patientId: 'pat-sara',
patientFirstName: 'Sara',
patientLastName: 'Hosseini',
providerUserId: userId,
purpose: 'endo',
},
];
const slots: TreatmentAppointment[] = [];
const times = [
[9, 0, 9, 45],
[11, 15, 12, 0],
[14, 30, 15, 30],
];
times.forEach(([h1, m1, h2, m2], i) => {
const baseSlot = base[i % base.length];
const startAt = new Date(y, m, d, h1, m1, 0, 0).toISOString();
const endAt = new Date(y, m, d, h2, m2, 0, 0).toISOString();
slots.push({
id: mockApptId(dayKey, i),
...baseSlot,
startAt,
endAt,
});
});
const today = new Date();
if (isSameLocalCalendarDay(day, today)) {
const now = today.getTime();
const start = new Date(now - 12 * 60 * 1000);
const end = new Date(now + 48 * 60 * 1000);
slots.unshift({
id: mockApptId(dayKey, 'now'),
patientId: 'pat-hesam',
patientFirstName: 'Hesam',
patientLastName: 'Aghaie',
providerUserId: userId,
startAt: start.toISOString(),
endAt: end.toISOString(),
purpose: 'visit',
});
}
return slots.sort((a, b) => new Date(a.startAt).getTime() - new Date(b.startAt).getTime());
}
const MOCK_HISTORY: Record<string, PastTreatment[]> = {
'pat-hesam': [
{
id: 'pt-h-1',
patientId: 'pat-hesam',
title: 'Root canal 45',
treatmentAt: new Date(2026, 8, 12).toISOString(),
status: 'completed',
records: [
{
id: 'ptr-1',
treatmentType: 'endo',
teeth: ['45'] as FdiToothId[],
notes: 'Instrumented and temporized.',
},
],
documents: [
{
id: 'doc-1',
fileName: 'periapical-45.png',
mimeType: 'image/png',
sizeBytes: 842_120,
},
{
id: 'doc-2',
fileName: 'consent-signed.pdf',
mimeType: 'application/pdf',
sizeBytes: 312_000,
},
],
},
{
id: 'pt-h-2',
patientId: 'pat-hesam',
title: 'Filling 14, 15',
treatmentAt: new Date(2025, 9, 10).toISOString(),
status: 'completed',
records: [
{
id: 'ptr-2',
treatmentType: 'filling',
teeth: ['14', '15'] as FdiToothId[],
notes: 'Composite restoration.',
},
],
documents: [
{
id: 'doc-3',
fileName: 'notes.txt',
mimeType: 'text/plain',
sizeBytes: 420,
},
],
},
],
'pat-alice': [
{
id: 'pt-a-1',
patientId: 'pat-alice',
title: 'Hygiene visit',
treatmentAt: addCalendarDays(new Date(), -21).toISOString(),
status: 'completed',
records: [{ id: 'ptr-a', treatmentType: 'hygiene', teeth: [], notes: 'Scale & polish.' }],
documents: [],
},
],
};
const MOCK_ORGS: LinkedOrganizationOption[] = [
{ id: 'org-lab-north', name: 'North Dental Lab', active: true },
{ id: 'org-lab-smile', name: 'Smile Ceramics', active: true },
{ id: 'org-lab-old', name: 'Legacy Lab (inactive)', active: false },
];
export async function fetchMyAppointmentsForDay(
userId: string,
day: Date,
): Promise<TreatmentAppointment[]> {
await sleep();
return buildSlotsForDay(day, userId);
}
export async function fetchPastTreatments(patientId: string): Promise<PastTreatment[]> {
await sleep();
return MOCK_HISTORY[patientId] ?? [];
}
export async function fetchLinkedOrganizations(): Promise<LinkedOrganizationOption[]> {
await sleep(180);
return MOCK_ORGS;
}
export async function saveTreatmentDraft(_payload: SaveTreatmentPayload): Promise<{ ok: true }> {
await sleep();
return { ok: true };
}
export async function sendTreatmentRecord(_payload: SendTreatmentRecordPayload): Promise<{ ok: true }> {
await sleep();
return { ok: true };
}

View File

@@ -0,0 +1,25 @@
import { isSameLocalCalendarDay } from '@/lib/appointmentTime';
import type { TreatmentAppointment } from '@/types/treatment';
/**
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
*/
export function pickAutoAppointment(
appointments: TreatmentAppointment[],
selectedDay: Date,
): string | null {
if (appointments.length === 0) return null;
const now = new Date();
if (isSameLocalCalendarDay(selectedDay, now)) {
const t = now.getTime();
for (const a of appointments) {
const s = new Date(a.startAt).getTime();
const e = new Date(a.endAt).getTime();
if (t >= s && t <= e) return a.id;
}
}
return appointments[0].id;
}

View File

@@ -48,3 +48,42 @@ export function canViewStaff(org: Organization | null): boolean {
hasPermission(org, 'TAB_STAFF_READ') || hasPermission(org, 'TAB_STAFF_EDIT')
);
}
/**
* Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns).
* Aligns with backend appointment mutations.
*/
export function canEditAppointments(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
/** Route + sidebar: view appointments page if user can read appointments or manage treatment (column staff). */
export function canAccessAppointmentsSection(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
/** Treatment composer, scheduling columns, and saving clinical workflows */
export function canEditTreatment(org: Organization | null): boolean {
if (!org) return false;
if (org.isOwner) return true;
return hasPermission(org, 'TAB_TREATMENT_EDIT');
}

View File

@@ -0,0 +1,27 @@
import type { Patient } from './patient';
export const APPOINTMENT_PURPOSES = [
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export interface AppointmentColumnProvider {
userId: string;
name: string;
}
export interface AppointmentRecord {
id: string;
organizationId: string;
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: string;
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'phone'>;
}

View File

@@ -0,0 +1,112 @@
/** Permanent dentition FDI ids (1148), stored as two-digit strings */
export type FdiToothId =
| '11'
| '12'
| '13'
| '14'
| '15'
| '16'
| '17'
| '18'
| '21'
| '22'
| '23'
| '24'
| '25'
| '26'
| '27'
| '28'
| '31'
| '32'
| '33'
| '34'
| '35'
| '36'
| '37'
| '38'
| '41'
| '42'
| '43'
| '44'
| '45'
| '46'
| '47'
| '48';
export interface TreatmentAppointment {
id: string;
patientId: string;
patientFirstName: string;
patientLastName: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: string;
}
export interface TreatmentAttachmentMeta {
id: string;
fileName: string;
mimeType: string;
sizeBytes: number;
}
export const TREATMENT_TYPES = [
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type TreatmentType = (typeof TREATMENT_TYPES)[number];
export interface PastTreatmentRecord {
id: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
notes?: string | null;
}
export interface PastTreatment {
id: string;
patientId: string;
title: string;
treatmentAt: string;
status: string;
records: PastTreatmentRecord[];
documents: TreatmentAttachmentMeta[];
}
export interface LinkedOrganizationOption {
id: string;
name: string;
active: boolean;
}
export interface TreatmentRecordDraft {
clientId: string;
treatmentType: TreatmentType;
teeth: FdiToothId[];
comment: string;
attachmentMetas: TreatmentAttachmentMeta[];
/** Organizations selected for sending this record (mock only until API exists) */
sendToOrganizationIds: string[];
sentAt?: string | null;
}
/** Payload for persisting a draft (mock API); omits ephemeral client-only fields */
export type SavedTreatmentRecordPayload = Omit<TreatmentRecordDraft, 'clientId' | 'sentAt'>;
export interface SaveTreatmentPayload {
appointmentId: string;
patientId: string;
records: SavedTreatmentRecordPayload[];
}
export interface SendTreatmentRecordPayload {
appointmentId: string;
patientId: string;
recordClientId: string;
organizationIds: string[];
}